feat(demo): add auth flow UI service
This commit is contained in:
@@ -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
|
||||
|
||||
14
README.md
14
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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
2
src/gapido_auth/demo/__init__.py
Normal file
2
src/gapido_auth/demo/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Demo browser client for the auth service."""
|
||||
|
||||
197
src/gapido_auth/demo/app.py
Normal file
197
src/gapido_auth/demo/app.py
Normal 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
|
||||
88
src/gapido_auth/demo/grpc_client.py
Normal file
88
src/gapido_auth/demo/grpc_client.py
Normal 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),
|
||||
}
|
||||
139
src/gapido_auth/demo/static/app.js
Normal file
139
src/gapido_auth/demo/static/app.js
Normal 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();
|
||||
|
||||
65
src/gapido_auth/demo/static/index.html
Normal file
65
src/gapido_auth/demo/static/index.html
Normal 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>
|
||||
|
||||
164
src/gapido_auth/demo/static/styles.css
Normal file
164
src/gapido_auth/demo/static/styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
26
src/gapido_auth/infrastructure/debug_sms_client.py
Normal file
26
src/gapido_auth/infrastructure/debug_sms_client.py
Normal 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}"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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__":
|
||||
|
||||
20
tests/test_debug_sms_client.py
Normal file
20
tests/test_debug_sms_client.py
Normal 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
132
tests/test_demo_app.py
Normal 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()
|
||||
Reference in New Issue
Block a user