docs: add architecture documentation and slides
This commit is contained in:
@@ -73,6 +73,15 @@ pytest
|
|||||||
|
|
||||||
The production Docker image installs runtime dependencies only. Development tools are installed locally through `.[dev]`.
|
The production Docker image installs runtime dependencies only. Development tools are installed locally through `.[dev]`.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Project documentation is available in [`docs/`](docs/README.md):
|
||||||
|
|
||||||
|
- [Architecture](docs/architecture.md)
|
||||||
|
- [Implementation decisions](docs/implementation-decisions.md)
|
||||||
|
- [Architecture decision records](docs/adr/README.md)
|
||||||
|
- [Presentation slides](docs/slides/index.html)
|
||||||
|
|
||||||
## gRPC Methods
|
## gRPC Methods
|
||||||
|
|
||||||
- `RequestOtp`: public; creates a short-lived OTP and publishes an SMS job.
|
- `RequestOtp`: public; creates a short-lived OTP and publishes an SMS job.
|
||||||
|
|||||||
21
docs/README.md
Normal file
21
docs/README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Documentation
|
||||||
|
|
||||||
|
This directory contains the design notes, diagrams, and presentation material for the Gapido backend code challenge.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [Architecture](architecture.md): service boundaries, flows, deployment modes, and runtime topology.
|
||||||
|
- [Implementation Decisions](implementation-decisions.md): key decisions and tradeoffs made during development.
|
||||||
|
- [ADR Index](adr/README.md): focused architectural decision records.
|
||||||
|
- [Slides](slides/index.html): static HTML presentation deck.
|
||||||
|
- [Diagrams](assets/diagrams/): generated PNG diagrams used by docs and slides.
|
||||||
|
|
||||||
|
## Regenerate Diagrams
|
||||||
|
|
||||||
|
Graphviz must be installed on the machine because the Python `diagrams` package renders through Graphviz.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
python docs/diagrams/generate.py
|
||||||
|
```
|
||||||
|
|
||||||
18
docs/adr/001-use-python-grpc.md
Normal file
18
docs/adr/001-use-python-grpc.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# ADR 001: Use Python gRPC for Auth
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The assessment requires Python with gRPC and focuses on microservice communication.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Implement the authentication boundary as an async `grpc.aio` service with protobuf contracts.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Service-to-service calls are strongly typed and efficient. Browser interaction needs a separate demo client service because native browser gRPC is not practical without grpc-web or a proxy.
|
||||||
|
|
||||||
18
docs/adr/002-redis-otp-state.md
Normal file
18
docs/adr/002-redis-otp-state.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# ADR 002: Store OTP State In Redis
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
OTP state is temporary, security-sensitive, and must expire automatically.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Store only HMAC hashes of OTP codes in Redis, with TTLs, attempt counters, and request rate limits.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
OTP verification is fast and self-expiring. Plaintext OTPs are not persisted. The local debug provider writes a separate development-only key for demos.
|
||||||
|
|
||||||
18
docs/adr/003-rabbitmq-sms-worker.md
Normal file
18
docs/adr/003-rabbitmq-sms-worker.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# ADR 003: Dispatch SMS Through RabbitMQ
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
SMS providers are external services and can fail or respond slowly.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Publish OTP SMS jobs to RabbitMQ and process them in a dedicated worker with manual acknowledgements, retries, and a dead-letter queue.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
The auth service remains responsive. SMS delivery is isolated and easier to retry, monitor, and replace.
|
||||||
|
|
||||||
18
docs/adr/004-sms-provider-strategy.md
Normal file
18
docs/adr/004-sms-provider-strategy.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# ADR 004: Select SMS Providers With Strategy
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The project supports Kavenegar, SMS.ir, and local debug delivery.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Expose a stable `SmsClient` port and choose the concrete provider through a small factory based on `SMS_PROVIDER`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Adding a new SMS provider does not change the auth use case or worker flow. Tests can mock providers without network access.
|
||||||
|
|
||||||
18
docs/adr/005-fastapi-demo-client.md
Normal file
18
docs/adr/005-fastapi-demo-client.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# ADR 005: Add FastAPI Demo Client Service
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The gRPC service needs a simple browser demo for interviews and reviewers.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Add `gapido_demo`, a small FastAPI backend-for-frontend that serves static UI and calls the auth service over gRPC.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
The demo shows how a separate microservice consumes auth. It remains separate from the auth service package and can be exposed safely through Caddy.
|
||||||
|
|
||||||
18
docs/adr/006-caddy-production-ingress.md
Normal file
18
docs/adr/006-caddy-production-ingress.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# ADR 006: Use Caddy For Production Ingress
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Production should expose only the demo UI under `gapido.amiirkhl.ir` with HTTPS.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Use Caddy as the reverse proxy and TLS terminator. Route public traffic to `demo-app:8080`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
TLS automation is simple. Internal services remain private on Docker networking.
|
||||||
|
|
||||||
18
docs/adr/007-compose-overlays.md
Normal file
18
docs/adr/007-compose-overlays.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# ADR 007: Split Local And Production Compose Overlays
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Local development needs direct access to service ports, while production must expose only Caddy.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Keep `docker-compose.yml` private by default, add `docker-compose.local.yml` for local ports, and add `docker-compose.prod.yml` for Caddy and production environment rules.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
The same services run locally and on a server, but exposure is controlled by the chosen Compose overlay.
|
||||||
|
|
||||||
10
docs/adr/README.md
Normal file
10
docs/adr/README.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Architecture Decision Records
|
||||||
|
|
||||||
|
- [ADR 001: Use Python gRPC for auth](001-use-python-grpc.md)
|
||||||
|
- [ADR 002: Store OTP state in Redis](002-redis-otp-state.md)
|
||||||
|
- [ADR 003: Dispatch SMS through RabbitMQ](003-rabbitmq-sms-worker.md)
|
||||||
|
- [ADR 004: Select SMS providers with Strategy](004-sms-provider-strategy.md)
|
||||||
|
- [ADR 005: Add FastAPI demo client service](005-fastapi-demo-client.md)
|
||||||
|
- [ADR 006: Use Caddy for production ingress](006-caddy-production-ingress.md)
|
||||||
|
- [ADR 007: Split local and production Compose overlays](007-compose-overlays.md)
|
||||||
|
|
||||||
40
docs/architecture.md
Normal file
40
docs/architecture.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
The project demonstrates a production-shaped authentication microservice built around gRPC, OTP login, asynchronous SMS dispatch, and a small demo client service.
|
||||||
|
|
||||||
|
## System View
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The browser talks only to `demo-app`. The demo service acts as a backend-for-frontend and calls the gRPC `auth-service`. Auth data is split by responsibility: MongoDB persists users and refresh sessions, Redis stores short-lived OTP state and rate-limit counters, and RabbitMQ decouples OTP generation from SMS delivery.
|
||||||
|
|
||||||
|
## Clean Architecture
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The domain layer owns roles, users, refresh sessions, and ports. The application layer coordinates OTP, tokens, and access control. Infrastructure adapters implement MongoDB, Redis, RabbitMQ, and SMS providers. Transports translate gRPC and demo HTTP requests into application use cases.
|
||||||
|
|
||||||
|
## OTP Login Flow
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
OTP codes are generated with `secrets`, stored only as HMAC hashes in Redis, and sent through RabbitMQ. Verification compares hashes and deletes successful OTP state before issuing an access token and opaque refresh token.
|
||||||
|
|
||||||
|
## Refresh Rotation
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Refresh tokens are opaque random values. Only SHA-256 hashes are stored in MongoDB. Every refresh creates a new session and revokes the old token hash.
|
||||||
|
|
||||||
|
## SMS Provider Strategy
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The worker depends on the `SmsClient` port and receives the concrete provider from a small factory. Kavenegar, SMS.ir, and debug-local delivery share the same interface.
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Production exposes only Caddy on ports `80` and `443`. Caddy routes `gapido.amiirkhl.ir` to `demo-app`. gRPC, MongoDB, Redis, RabbitMQ, and the SMS worker remain private on the Docker network.
|
||||||
|
|
||||||
BIN
docs/assets/diagrams/clean_architecture.png
Normal file
BIN
docs/assets/diagrams/clean_architecture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
docs/assets/diagrams/otp_login_flow.png
Normal file
BIN
docs/assets/diagrams/otp_login_flow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
BIN
docs/assets/diagrams/production_deployment.png
Normal file
BIN
docs/assets/diagrams/production_deployment.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
docs/assets/diagrams/refresh_rotation_flow.png
Normal file
BIN
docs/assets/diagrams/refresh_rotation_flow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
BIN
docs/assets/diagrams/service_architecture.png
Normal file
BIN
docs/assets/diagrams/service_architecture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
BIN
docs/assets/diagrams/sms_provider_strategy.png
Normal file
BIN
docs/assets/diagrams/sms_provider_strategy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
215
docs/diagrams/generate.py
Normal file
215
docs/diagrams/generate.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from diagrams import Cluster, Diagram, Edge
|
||||||
|
from diagrams.generic.blank import Blank
|
||||||
|
from diagrams.onprem.database import Mongodb
|
||||||
|
from diagrams.onprem.inmemory import Redis
|
||||||
|
from diagrams.onprem.network import Internet
|
||||||
|
from diagrams.onprem.queue import Rabbitmq
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
OUT = ROOT / "docs" / "assets" / "diagrams"
|
||||||
|
|
||||||
|
|
||||||
|
def graph_attr() -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"bgcolor": "transparent",
|
||||||
|
"pad": "0.35",
|
||||||
|
"ranksep": "0.8",
|
||||||
|
"nodesep": "0.55",
|
||||||
|
"fontname": "Inter",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def node_attr() -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"fontname": "Inter",
|
||||||
|
"fontsize": "13",
|
||||||
|
"style": "rounded,filled",
|
||||||
|
"fillcolor": "#111827",
|
||||||
|
"fontcolor": "#E5E7EB",
|
||||||
|
"color": "#334155",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def edge_attr() -> dict[str, str]:
|
||||||
|
return {"fontname": "Inter", "fontsize": "11", "color": "#64748B", "fontcolor": "#475569"}
|
||||||
|
|
||||||
|
|
||||||
|
def render_service_architecture() -> None:
|
||||||
|
with Diagram(
|
||||||
|
"Service Architecture",
|
||||||
|
filename=str(OUT / "service_architecture"),
|
||||||
|
show=False,
|
||||||
|
direction="LR",
|
||||||
|
graph_attr=graph_attr(),
|
||||||
|
node_attr=node_attr(),
|
||||||
|
edge_attr=edge_attr(),
|
||||||
|
):
|
||||||
|
browser = Internet("Browser")
|
||||||
|
demo = Blank("demo-app\nFastAPI UI")
|
||||||
|
auth = Blank("auth-service\ngRPC")
|
||||||
|
worker = Blank("sms-worker")
|
||||||
|
mongo = Mongodb("MongoDB")
|
||||||
|
redis = Redis("Redis")
|
||||||
|
rabbit = Rabbitmq("RabbitMQ")
|
||||||
|
providers = Blank("Kavenegar / SMS.ir")
|
||||||
|
|
||||||
|
browser >> Edge(label="HTTPS / local HTTP") >> demo
|
||||||
|
demo >> Edge(label="gRPC") >> auth
|
||||||
|
auth >> Edge(label="users + refresh sessions") >> mongo
|
||||||
|
auth >> Edge(label="OTP hash + rate limits") >> redis
|
||||||
|
auth >> Edge(label="SMS job") >> rabbit >> worker >> providers
|
||||||
|
|
||||||
|
|
||||||
|
def render_clean_architecture() -> None:
|
||||||
|
with Diagram(
|
||||||
|
"Clean Architecture Layers",
|
||||||
|
filename=str(OUT / "clean_architecture"),
|
||||||
|
show=False,
|
||||||
|
direction="TB",
|
||||||
|
graph_attr=graph_attr(),
|
||||||
|
node_attr=node_attr(),
|
||||||
|
edge_attr=edge_attr(),
|
||||||
|
):
|
||||||
|
with Cluster("Transport"):
|
||||||
|
grpc = Blank("gRPC servicer")
|
||||||
|
demo = Blank("Demo BFF")
|
||||||
|
with Cluster("Application"):
|
||||||
|
auth = Blank("AuthService\nuse cases")
|
||||||
|
security = Blank("Token + OTP helpers")
|
||||||
|
with Cluster("Domain"):
|
||||||
|
entities = Blank("Entities")
|
||||||
|
ports = Blank("Ports")
|
||||||
|
with Cluster("Infrastructure"):
|
||||||
|
mongo = Blank("Mongo repositories")
|
||||||
|
redis = Blank("Redis OTP store")
|
||||||
|
rabbit = Blank("RabbitMQ publisher")
|
||||||
|
sms = Blank("SMS strategies")
|
||||||
|
|
||||||
|
grpc >> auth
|
||||||
|
demo >> grpc
|
||||||
|
auth >> entities
|
||||||
|
auth >> ports
|
||||||
|
auth >> security
|
||||||
|
ports >> mongo
|
||||||
|
ports >> redis
|
||||||
|
ports >> rabbit
|
||||||
|
ports >> sms
|
||||||
|
|
||||||
|
|
||||||
|
def render_otp_flow() -> None:
|
||||||
|
with Diagram(
|
||||||
|
"OTP Login Flow",
|
||||||
|
filename=str(OUT / "otp_login_flow"),
|
||||||
|
show=False,
|
||||||
|
direction="LR",
|
||||||
|
graph_attr=graph_attr(),
|
||||||
|
node_attr=node_attr(),
|
||||||
|
edge_attr=edge_attr(),
|
||||||
|
):
|
||||||
|
user = Blank("User")
|
||||||
|
demo = Blank("demo-app")
|
||||||
|
auth = Blank("auth-service")
|
||||||
|
redis = Redis("Redis")
|
||||||
|
rabbit = Rabbitmq("RabbitMQ")
|
||||||
|
worker = Blank("sms-worker")
|
||||||
|
sms = Blank("SMS provider")
|
||||||
|
mongo = Mongodb("MongoDB")
|
||||||
|
|
||||||
|
user >> Edge(label="mobile") >> demo >> Edge(label="RequestOtp") >> auth
|
||||||
|
auth >> Edge(label="store OTP hash") >> redis
|
||||||
|
auth >> Edge(label="publish job") >> rabbit >> worker >> sms
|
||||||
|
user >> Edge(label="code") >> demo >> Edge(label="VerifyOtp") >> auth
|
||||||
|
auth >> Edge(label="compare hash") >> redis
|
||||||
|
auth >> Edge(label="upsert user + session") >> mongo
|
||||||
|
auth >> Edge(label="token pair") >> demo >> user
|
||||||
|
|
||||||
|
|
||||||
|
def render_refresh_flow() -> None:
|
||||||
|
with Diagram(
|
||||||
|
"Refresh Token Rotation",
|
||||||
|
filename=str(OUT / "refresh_rotation_flow"),
|
||||||
|
show=False,
|
||||||
|
direction="LR",
|
||||||
|
graph_attr=graph_attr(),
|
||||||
|
node_attr=node_attr(),
|
||||||
|
edge_attr=edge_attr(),
|
||||||
|
):
|
||||||
|
client = Blank("Client")
|
||||||
|
auth = Blank("auth-service")
|
||||||
|
mongo = Mongodb("MongoDB")
|
||||||
|
old = Blank("Old session\nrevoked")
|
||||||
|
new = Blank("New session\nactive")
|
||||||
|
|
||||||
|
client >> Edge(label="refresh token") >> auth
|
||||||
|
auth >> Edge(label="hash lookup") >> mongo
|
||||||
|
mongo >> Edge(label="active session") >> auth
|
||||||
|
auth >> old
|
||||||
|
auth >> new
|
||||||
|
new >> Edge(label="new token pair") >> client
|
||||||
|
|
||||||
|
|
||||||
|
def render_sms_strategy() -> None:
|
||||||
|
with Diagram(
|
||||||
|
"SMS Provider Strategy",
|
||||||
|
filename=str(OUT / "sms_provider_strategy"),
|
||||||
|
show=False,
|
||||||
|
direction="LR",
|
||||||
|
graph_attr=graph_attr(),
|
||||||
|
node_attr=node_attr(),
|
||||||
|
edge_attr=edge_attr(),
|
||||||
|
):
|
||||||
|
worker = Blank("sms-worker")
|
||||||
|
factory = Blank("create_sms_client")
|
||||||
|
port = Blank("SmsClient port")
|
||||||
|
kav = Blank("Kavenegar")
|
||||||
|
smsir = Blank("SMS.ir")
|
||||||
|
debug = Blank("Debug local")
|
||||||
|
|
||||||
|
worker >> factory >> port
|
||||||
|
port >> kav
|
||||||
|
port >> smsir
|
||||||
|
port >> debug
|
||||||
|
|
||||||
|
|
||||||
|
def render_production_deployment() -> None:
|
||||||
|
with Diagram(
|
||||||
|
"Production Deployment",
|
||||||
|
filename=str(OUT / "production_deployment"),
|
||||||
|
show=False,
|
||||||
|
direction="LR",
|
||||||
|
graph_attr=graph_attr(),
|
||||||
|
node_attr=node_attr(),
|
||||||
|
edge_attr=edge_attr(),
|
||||||
|
):
|
||||||
|
internet = Internet("Internet")
|
||||||
|
caddy = Blank("Caddy\nHTTPS")
|
||||||
|
demo = Blank("demo-app")
|
||||||
|
auth = Blank("auth-service")
|
||||||
|
private = Blank("Private Docker network")
|
||||||
|
mongo = Mongodb("MongoDB")
|
||||||
|
redis = Redis("Redis")
|
||||||
|
rabbit = Rabbitmq("RabbitMQ")
|
||||||
|
worker = Blank("sms-worker")
|
||||||
|
|
||||||
|
internet >> Edge(label="gapido.amiirkhl.ir") >> caddy >> demo >> auth
|
||||||
|
auth >> private
|
||||||
|
private >> mongo
|
||||||
|
private >> redis
|
||||||
|
private >> rabbit >> worker
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
OUT.mkdir(parents=True, exist_ok=True)
|
||||||
|
render_service_architecture()
|
||||||
|
render_clean_architecture()
|
||||||
|
render_otp_flow()
|
||||||
|
render_refresh_flow()
|
||||||
|
render_sms_strategy()
|
||||||
|
render_production_deployment()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
34
docs/implementation-decisions.md
Normal file
34
docs/implementation-decisions.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Implementation Decisions
|
||||||
|
|
||||||
|
## Python gRPC Service
|
||||||
|
|
||||||
|
The challenge explicitly required Python with gRPC. The auth boundary is therefore a `grpc.aio` service instead of REST. A separate FastAPI demo service was added only as a browser-friendly client.
|
||||||
|
|
||||||
|
## OTP Storage
|
||||||
|
|
||||||
|
OTP codes are never stored in plaintext. Redis stores HMAC hashes with a short TTL, attempt counters, and request rate-limit counters. This keeps OTP state fast, temporary, and easy to expire.
|
||||||
|
|
||||||
|
## Token Model
|
||||||
|
|
||||||
|
Access tokens are signed JWTs with short TTLs. Refresh tokens are opaque random values, stored only as hashes in MongoDB, and rotated on every use.
|
||||||
|
|
||||||
|
## RabbitMQ SMS Dispatch
|
||||||
|
|
||||||
|
SMS delivery is asynchronous. The auth service publishes a durable message and returns quickly. The worker uses manual acknowledgement, bounded retries, and a dead-letter queue for failed deliveries.
|
||||||
|
|
||||||
|
## SMS Provider Strategy
|
||||||
|
|
||||||
|
Kavenegar, SMS.ir, and debug delivery implement the same `SmsClient` port. The provider is selected through configuration, which keeps the worker closed for modification when adding providers.
|
||||||
|
|
||||||
|
## Demo Service
|
||||||
|
|
||||||
|
Browsers do not speak native gRPC directly. A small FastAPI backend-for-frontend demonstrates how another microservice consumes the auth service through gRPC while serving a minimal UI.
|
||||||
|
|
||||||
|
## Local vs Production Compose
|
||||||
|
|
||||||
|
The base Compose file is production-safe and keeps service ports private. `docker-compose.local.yml` publishes developer ports. `docker-compose.prod.yml` adds Caddy as the only public entrypoint.
|
||||||
|
|
||||||
|
## Caddy Reverse Proxy
|
||||||
|
|
||||||
|
Caddy was chosen for automatic HTTPS and a compact configuration. Production serves only `https://gapido.amiirkhl.ir`; internal service ports are not published.
|
||||||
|
|
||||||
103
docs/slides/index.html
Normal file
103
docs/slides/index.html
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Gapido Auth Challenge</title>
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<span>Gapido Backend Challenge</span>
|
||||||
|
<div class="controls">
|
||||||
|
<button id="theme-toggle" type="button">Toggle theme</button>
|
||||||
|
<button id="prev" type="button">Prev</button>
|
||||||
|
<button id="next" type="button">Next</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="deck">
|
||||||
|
<section class="slide active">
|
||||||
|
<p class="eyebrow">The problem</p>
|
||||||
|
<h1>Secure OTP auth for microservices</h1>
|
||||||
|
<p class="lede">Python • gRPC • MongoDB • Redis • RabbitMQ • Docker</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="slide split">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Constraints</p>
|
||||||
|
<h2>What had to be proven</h2>
|
||||||
|
</div>
|
||||||
|
<ul class="tiles">
|
||||||
|
<li>gRPC auth boundary</li>
|
||||||
|
<li>secure OTP</li>
|
||||||
|
<li>refresh rotation</li>
|
||||||
|
<li>role-based access</li>
|
||||||
|
<li>async SMS delivery</li>
|
||||||
|
<li>production deployment path</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="slide diagram">
|
||||||
|
<p class="eyebrow">Solution shape</p>
|
||||||
|
<h2>Service architecture</h2>
|
||||||
|
<img src="../assets/diagrams/service_architecture.png" alt="Service architecture" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="slide diagram">
|
||||||
|
<p class="eyebrow">Code organization</p>
|
||||||
|
<h2>Clean architecture layers</h2>
|
||||||
|
<img src="../assets/diagrams/clean_architecture.png" alt="Clean architecture layers" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="slide diagram">
|
||||||
|
<p class="eyebrow">Auth flow</p>
|
||||||
|
<h2>OTP without storing plaintext codes</h2>
|
||||||
|
<img src="../assets/diagrams/otp_login_flow.png" alt="OTP login flow" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="slide diagram">
|
||||||
|
<p class="eyebrow">Token safety</p>
|
||||||
|
<h2>Refresh token rotation</h2>
|
||||||
|
<img src="../assets/diagrams/refresh_rotation_flow.png" alt="Refresh token rotation" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="slide diagram">
|
||||||
|
<p class="eyebrow">Extensibility</p>
|
||||||
|
<h2>Selectable SMS providers</h2>
|
||||||
|
<img src="../assets/diagrams/sms_provider_strategy.png" alt="SMS provider strategy" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="slide diagram">
|
||||||
|
<p class="eyebrow">Deployment</p>
|
||||||
|
<h2>Only Caddy is public</h2>
|
||||||
|
<img src="../assets/diagrams/production_deployment.png" alt="Production deployment" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="slide split">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Reliability</p>
|
||||||
|
<h2>What makes it reviewable</h2>
|
||||||
|
</div>
|
||||||
|
<ul class="tiles">
|
||||||
|
<li>Mocked SMS providers</li>
|
||||||
|
<li>gRPC tests</li>
|
||||||
|
<li>RBAC coverage</li>
|
||||||
|
<li>Compose validation</li>
|
||||||
|
<li>Health checks</li>
|
||||||
|
<li>Postman guide</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="slide">
|
||||||
|
<p class="eyebrow">Outcome</p>
|
||||||
|
<h2>Interview-ready microservice demo</h2>
|
||||||
|
<p class="lede">A focused auth service with clean boundaries, secure OTP, async messaging, provider strategy, and production deployment posture.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="progress"><span id="counter">1 / 10</span></footer>
|
||||||
|
<script src="slides.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
28
docs/slides/slides.js
Normal file
28
docs/slides/slides.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
const slides = [...document.querySelectorAll(".slide")];
|
||||||
|
const counter = document.querySelector("#counter");
|
||||||
|
const root = document.documentElement;
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
slides.forEach((slide, current) => slide.classList.toggle("active", current === index));
|
||||||
|
counter.textContent = `${index + 1} / ${slides.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(delta) {
|
||||||
|
index = (index + delta + slides.length) % slides.length;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelector("#next").addEventListener("click", () => move(1));
|
||||||
|
document.querySelector("#prev").addEventListener("click", () => move(-1));
|
||||||
|
document.querySelector("#theme-toggle").addEventListener("click", () => {
|
||||||
|
root.classList.toggle("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "ArrowRight" || event.key === " ") move(1);
|
||||||
|
if (event.key === "ArrowLeft") move(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
render();
|
||||||
|
|
||||||
178
docs/slides/styles.css
Normal file
178
docs/slides/styles.css
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #070b14;
|
||||||
|
--panel: #101827;
|
||||||
|
--text: #e6edf7;
|
||||||
|
--muted: #93a4bc;
|
||||||
|
--accent: #34d399;
|
||||||
|
--border: #223149;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.light {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f7fafc;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--text: #172033;
|
||||||
|
--muted: #5f7088;
|
||||||
|
--accent: #0f766e;
|
||||||
|
--border: #d8e1ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar,
|
||||||
|
.progress {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 24px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 72px 5vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide {
|
||||||
|
display: none;
|
||||||
|
width: min(1120px, 100%);
|
||||||
|
min-height: 620px;
|
||||||
|
padding: 48px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: 0 24px 80px rgb(0 0 0 / 24%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide.active {
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split.active {
|
||||||
|
grid-template-columns: 0.9fr 1.1fr;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram.active {
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
max-width: 900px;
|
||||||
|
font-size: clamp(54px, 7vw, 92px);
|
||||||
|
line-height: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: clamp(36px, 5vw, 62px);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lede {
|
||||||
|
max-width: 820px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiles {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiles li {
|
||||||
|
min-height: 96px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
background: color-mix(in srgb, var(--panel), var(--accent) 6%);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 460px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.slide {
|
||||||
|
min-height: 620px;
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split.active,
|
||||||
|
.tiles {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ dependencies = [
|
|||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
"diagrams==0.24.4",
|
||||||
"grpcio-tools==1.68.1",
|
"grpcio-tools==1.68.1",
|
||||||
"mypy==1.14.1",
|
"mypy==1.14.1",
|
||||||
"pytest==8.3.4",
|
"pytest==8.3.4",
|
||||||
|
|||||||
Reference in New Issue
Block a user