feat(demo): add auth flow UI service

This commit is contained in:
2026-07-14 10:12:22 +03:30
parent e382c4f93a
commit d43eb54e2d
16 changed files with 895 additions and 10 deletions

View File

@@ -11,11 +11,16 @@ OTP_TTL_SECONDS=120
OTP_MAX_ATTEMPTS=5 OTP_MAX_ATTEMPTS=5
OTP_REQUEST_LIMIT=3 OTP_REQUEST_LIMIT=3
OTP_REQUEST_WINDOW_SECONDS=300 OTP_REQUEST_WINDOW_SECONDS=300
SMS_PROVIDER=kavenegar SMS_PROVIDER=debug
KAVENEGAR_API_KEY=replace-with-real-key KAVENEGAR_API_KEY=replace-with-real-key
KAVENEGAR_LOGIN_TEMPLATE=login-otp KAVENEGAR_LOGIN_TEMPLATE=login-otp
SMS_IR_API_KEY=replace-with-real-key SMS_IR_API_KEY=replace-with-real-key
SMS_IR_VERIFY_TEMPLATE_ID=570574 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 ADMIN_MOBILE=989120000000
GRPC_HOST=0.0.0.0 GRPC_HOST=0.0.0.0
GRPC_PORT=50051 GRPC_PORT=50051

View File

@@ -17,7 +17,7 @@ cp .env.example .env
docker compose up --build 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 ## SMS Provider
@@ -31,6 +31,18 @@ Supported values:
- `kavenegar`: uses `KAVENEGAR_API_KEY` and `KAVENEGAR_LOGIN_TEMPLATE`. - `kavenegar`: uses `KAVENEGAR_API_KEY` and `KAVENEGAR_LOGIN_TEMPLATE`.
- `sms_ir`: uses `SMS_IR_API_KEY` and `SMS_IR_VERIFY_TEMPLATE_ID`. - `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 ## Local Development

View File

@@ -15,7 +15,7 @@ services:
OTP_MAX_ATTEMPTS: ${OTP_MAX_ATTEMPTS:-5} OTP_MAX_ATTEMPTS: ${OTP_MAX_ATTEMPTS:-5}
OTP_REQUEST_LIMIT: ${OTP_REQUEST_LIMIT:-3} OTP_REQUEST_LIMIT: ${OTP_REQUEST_LIMIT:-3}
OTP_REQUEST_WINDOW_SECONDS: ${OTP_REQUEST_WINDOW_SECONDS:-300} 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} KAVENEGAR_LOGIN_TEMPLATE: ${KAVENEGAR_LOGIN_TEMPLATE:-login-otp}
SMS_IR_VERIFY_TEMPLATE_ID: ${SMS_IR_VERIFY_TEMPLATE_ID:-570574} SMS_IR_VERIFY_TEMPLATE_ID: ${SMS_IR_VERIFY_TEMPLATE_ID:-570574}
ADMIN_MOBILE: ${ADMIN_MOBILE:-989120000000} ADMIN_MOBILE: ${ADMIN_MOBILE:-989120000000}
@@ -38,13 +38,28 @@ services:
MONGO_URI: mongodb://mongo:27017 MONGO_URI: mongodb://mongo:27017
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
RABBITMQ_URL: amqp://guest:guest@rabbitmq:5672/ 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} KAVENEGAR_API_KEY: ${KAVENEGAR_API_KEY:-replace-with-real-key}
SMS_IR_API_KEY: ${SMS_IR_API_KEY:-replace-with-real-key} SMS_IR_API_KEY: ${SMS_IR_API_KEY:-replace-with-real-key}
depends_on: depends_on:
rabbitmq: rabbitmq:
condition: service_healthy 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: mongo:
image: mongo:7 image: mongo:7
ports: ports:

View File

@@ -5,6 +5,7 @@ description = "Gapido backend code challenge: gRPC OTP auth service with MongoDB
requires-python = ">=3.12,<3.14" requires-python = ">=3.12,<3.14"
dependencies = [ dependencies = [
"aio-pika==9.5.5", "aio-pika==9.5.5",
"fastapi==0.115.6",
"grpcio==1.68.1", "grpcio==1.68.1",
"grpcio-health-checking==1.68.1", "grpcio-health-checking==1.68.1",
"grpcio-reflection==1.68.1", "grpcio-reflection==1.68.1",
@@ -16,6 +17,7 @@ dependencies = [
"pydantic-settings==2.7.1", "pydantic-settings==2.7.1",
"pyjwt==2.10.1", "pyjwt==2.10.1",
"redis==5.2.1", "redis==5.2.1",
"uvicorn==0.34.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -23,7 +23,7 @@ class Settings(BaseSettings):
otp_request_limit: int = 3 otp_request_limit: int = 3
otp_request_window_seconds: int = 300 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_api_key: str = "replace-with-real-key"
kavenegar_login_template: str = "login-otp" kavenegar_login_template: str = "login-otp"
@@ -31,6 +31,12 @@ class Settings(BaseSettings):
sms_ir_api_key: str = "replace-with-real-key" sms_ir_api_key: str = "replace-with-real-key"
sms_ir_verify_template_id: int = 570574 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" admin_mobile: str = "989120000000"
grpc_host: str = "0.0.0.0" grpc_host: str = "0.0.0.0"
grpc_port: int = 50051 grpc_port: int = 50051

View File

@@ -0,0 +1,2 @@
"""Demo browser client for the auth service."""

197
src/gapido_auth/demo/app.py Normal file
View File

@@ -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

View File

@@ -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),
}

View File

@@ -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();

View File

@@ -0,0 +1,65 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Gapido Auth Demo</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<main class="shell">
<section class="panel identity">
<div>
<p class="eyebrow">Microservice Auth Demo</p>
<h1>Gapido gRPC Auth</h1>
</div>
<div class="status" id="auth-status">Signed out</div>
</section>
<section class="grid">
<form class="panel stack" id="login-form">
<h2>OTP Login</h2>
<label>
Mobile
<input id="mobile" name="mobile" value="989120000000" autocomplete="tel" />
</label>
<div class="actions">
<button type="button" id="request-otp">Request OTP</button>
<button type="button" id="debug-otp">Use Debug OTP</button>
</div>
<label>
OTP
<input id="otp" name="otp" maxlength="6" inputmode="numeric" />
</label>
<button type="submit">Verify and Sign In</button>
</form>
<section class="panel stack">
<h2>Session</h2>
<dl class="session">
<div><dt>Role</dt><dd id="role">-</dd></div>
<div><dt>Access TTL</dt><dd id="ttl">-</dd></div>
<div><dt>Token</dt><dd id="token-preview">-</dd></div>
</dl>
<div class="actions">
<button type="button" id="refresh">Refresh</button>
<button type="button" id="logout">Logout</button>
</div>
</section>
</section>
<section class="panel stack">
<h2>Service Calls</h2>
<div class="actions">
<button type="button" id="public">Public</button>
<button type="button" id="user">User Only</button>
<button type="button" id="admin">Admin Only</button>
</div>
<pre id="result">{ "ready": true }</pre>
</section>
</main>
<script src="/static/app.js"></script>
</body>
</html>

View File

@@ -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;
}
}

View File

@@ -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}"

View File

@@ -2,6 +2,7 @@ import httpx
from gapido_auth.config import Settings from gapido_auth.config import Settings
from gapido_auth.domain.ports import SmsClient 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.kavenegar_client import KavenegarSmsClient
from gapido_auth.infrastructure.sms_ir_client import SmsIrSmsClient 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( def create_sms_client(
settings: Settings, settings: Settings,
transport: httpx.AsyncBaseTransport | None = None, transport: httpx.AsyncBaseTransport | None = None,
debug_store: DebugSmsStore | None = None,
) -> SmsClient: ) -> SmsClient:
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)
case "sms_ir": case "sms_ir":
return SmsIrSmsClient(settings.sms_ir_api_key, transport=transport) 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: def get_sms_template(settings: Settings) -> str:
@@ -23,4 +29,5 @@ def get_sms_template(settings: Settings) -> str:
return settings.kavenegar_login_template return settings.kavenegar_login_template
case "sms_ir": case "sms_ir":
return str(settings.sms_ir_verify_template_id) return str(settings.sms_ir_verify_template_id)
case "debug":
return "debug-otp"

View File

@@ -5,6 +5,7 @@ from typing import Any, cast
import aio_pika import aio_pika
from aio_pika.abc import AbstractIncomingMessage from aio_pika.abc import AbstractIncomingMessage
from redis.asyncio import Redis
from gapido_auth.config import get_settings from gapido_auth.config import get_settings
from gapido_auth.domain.entities import SmsJob from gapido_auth.domain.entities import SmsJob
@@ -69,10 +70,14 @@ async def main() -> None:
channel = await connection.channel() channel = await connection.channel()
await channel.set_qos(prefetch_count=20) await channel.set_qos(prefetch_count=20)
exchange, queue, dlx = await declare_sms_topology(channel) exchange, queue, dlx = await declare_sms_topology(channel)
client = create_sms_client(settings) redis = Redis.from_url(settings.redis_url, decode_responses=True)
await queue.consume(lambda message: handle_message(message, client, exchange, dlx)) try:
logger.info("SMS worker started with provider=%s", settings.sms_provider) client = create_sms_client(settings, debug_store=redis)
await asyncio.Future() 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__": if __name__ == "__main__":

View File

@@ -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")

132
tests/test_demo_app.py Normal file
View File

@@ -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()