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_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
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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.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"
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
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