chore: add project scaffold
This commit is contained in:
19
.env.example
Normal file
19
.env.example
Normal file
@@ -0,0 +1,19 @@
|
||||
APP_ENV=local
|
||||
MONGO_URI=mongodb://localhost:27017
|
||||
MONGO_DB_NAME=gapido_auth
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
RABBITMQ_URL=amqp://guest:guest@localhost:5672/
|
||||
JWT_SECRET_KEY=change-me-use-a-long-random-secret
|
||||
JWT_ISSUER=gapido-auth
|
||||
ACCESS_TOKEN_TTL_SECONDS=900
|
||||
REFRESH_TOKEN_TTL_SECONDS=604800
|
||||
OTP_TTL_SECONDS=120
|
||||
OTP_MAX_ATTEMPTS=5
|
||||
OTP_REQUEST_LIMIT=3
|
||||
OTP_REQUEST_WINDOW_SECONDS=300
|
||||
KAVENEGAR_API_KEY=replace-with-real-key
|
||||
KAVENEGAR_LOGIN_TEMPLATE=login-otp
|
||||
ADMIN_MOBILE=989120000000
|
||||
GRPC_HOST=0.0.0.0
|
||||
GRPC_PORT=50051
|
||||
|
||||
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Test and tooling caches
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Local environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# IDE and OS
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Docker/local runtime data
|
||||
mongo-data/
|
||||
redis-data/
|
||||
rabbitmq-data/
|
||||
|
||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM python:3.12-slim AS runtime
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/app/src
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY src ./src
|
||||
COPY proto ./proto
|
||||
|
||||
RUN pip install --no-cache-dir ".[dev]" \
|
||||
&& python -m gapido_auth.tools.generate_proto
|
||||
|
||||
CMD ["python", "-m", "gapido_auth.transport.grpc.server"]
|
||||
|
||||
20
Makefile
Normal file
20
Makefile
Normal file
@@ -0,0 +1,20 @@
|
||||
.PHONY: proto test lint typecheck compose-up compose-down
|
||||
|
||||
proto:
|
||||
python -m gapido_auth.tools.generate_proto
|
||||
|
||||
test:
|
||||
pytest
|
||||
|
||||
lint:
|
||||
ruff check .
|
||||
|
||||
typecheck:
|
||||
mypy
|
||||
|
||||
compose-up:
|
||||
docker compose up --build
|
||||
|
||||
compose-down:
|
||||
docker compose down --remove-orphans
|
||||
|
||||
54
README.md
Normal file
54
README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Gapido Backend Code Challenge
|
||||
|
||||
Python gRPC OTP authentication service with MongoDB, Redis, RabbitMQ, and a Kavenegar SMS adapter.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `auth-service`: async `grpc.aio` API for OTP login, token refresh, token revocation, and RBAC demo methods.
|
||||
- `sms-worker`: RabbitMQ consumer that sends OTP messages through Kavenegar.
|
||||
- MongoDB stores users and refresh-token sessions.
|
||||
- Redis stores OTP hashes, TTL, verification attempts, and OTP request rate limits.
|
||||
- RabbitMQ decouples authentication from SMS delivery.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
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`.
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
pip install -e ".[dev]"
|
||||
python -m gapido_auth.tools.generate_proto
|
||||
pytest
|
||||
```
|
||||
|
||||
## gRPC Methods
|
||||
|
||||
- `RequestOtp`: public; creates a short-lived OTP and publishes an SMS job.
|
||||
- `VerifyOtp`: public; verifies OTP and returns access and refresh tokens.
|
||||
- `RefreshToken`: public; rotates refresh token and returns a new token pair.
|
||||
- `RevokeRefreshToken`: authenticated; revokes a refresh token session.
|
||||
- `PublicPing`: public.
|
||||
- `UserOnly`: requires any authenticated active user.
|
||||
- `AdminOnly`: requires an authenticated admin.
|
||||
|
||||
Protected calls use metadata:
|
||||
|
||||
```text
|
||||
authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- OTP codes are generated with `secrets`, stored only as HMAC hashes in Redis, and expire after 120 seconds by default.
|
||||
- OTP requests are rate-limited per mobile number and client identity.
|
||||
- Refresh tokens are opaque random values; only SHA-256 hashes are persisted.
|
||||
- Refresh tokens rotate on use.
|
||||
- Kavenegar is hidden behind an adapter and mocked in tests.
|
||||
|
||||
68
docker-compose.yml
Normal file
68
docker-compose.yml
Normal file
@@ -0,0 +1,68 @@
|
||||
services:
|
||||
auth-service:
|
||||
build: .
|
||||
command: python -m gapido_auth.transport.grpc.server
|
||||
environment:
|
||||
MONGO_URI: mongodb://mongo:27017
|
||||
MONGO_DB_NAME: ${MONGO_DB_NAME:-gapido_auth}
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
RABBITMQ_URL: amqp://guest:guest@rabbitmq:5672/
|
||||
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-me-use-a-long-random-secret}
|
||||
JWT_ISSUER: ${JWT_ISSUER:-gapido-auth}
|
||||
ACCESS_TOKEN_TTL_SECONDS: ${ACCESS_TOKEN_TTL_SECONDS:-900}
|
||||
REFRESH_TOKEN_TTL_SECONDS: ${REFRESH_TOKEN_TTL_SECONDS:-604800}
|
||||
OTP_TTL_SECONDS: ${OTP_TTL_SECONDS:-120}
|
||||
OTP_MAX_ATTEMPTS: ${OTP_MAX_ATTEMPTS:-5}
|
||||
OTP_REQUEST_LIMIT: ${OTP_REQUEST_LIMIT:-3}
|
||||
OTP_REQUEST_WINDOW_SECONDS: ${OTP_REQUEST_WINDOW_SECONDS:-300}
|
||||
KAVENEGAR_LOGIN_TEMPLATE: ${KAVENEGAR_LOGIN_TEMPLATE:-login-otp}
|
||||
ADMIN_MOBILE: ${ADMIN_MOBILE:-989120000000}
|
||||
GRPC_HOST: 0.0.0.0
|
||||
GRPC_PORT: 50051
|
||||
ports:
|
||||
- "50051:50051"
|
||||
depends_on:
|
||||
mongo:
|
||||
condition: service_started
|
||||
redis:
|
||||
condition: service_started
|
||||
rabbitmq:
|
||||
condition: service_healthy
|
||||
|
||||
sms-worker:
|
||||
build: .
|
||||
command: python -m gapido_auth.infrastructure.worker
|
||||
environment:
|
||||
MONGO_URI: mongodb://mongo:27017
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
RABBITMQ_URL: amqp://guest:guest@rabbitmq:5672/
|
||||
KAVENEGAR_API_KEY: ${KAVENEGAR_API_KEY:-replace-with-real-key}
|
||||
depends_on:
|
||||
rabbitmq:
|
||||
condition: service_healthy
|
||||
|
||||
mongo:
|
||||
image: mongo:7
|
||||
ports:
|
||||
- "27017:27017"
|
||||
volumes:
|
||||
- mongo-data:/data/db
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:3.13-management-alpine
|
||||
ports:
|
||||
- "5672:5672"
|
||||
- "15672:15672"
|
||||
healthcheck:
|
||||
test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
volumes:
|
||||
mongo-data:
|
||||
69
pyproject.toml
Normal file
69
pyproject.toml
Normal file
@@ -0,0 +1,69 @@
|
||||
[project]
|
||||
name = "gapido-auth-service"
|
||||
version = "0.1.0"
|
||||
description = "Gapido backend code challenge: gRPC OTP auth service with MongoDB, Redis, RabbitMQ, and Kavenegar."
|
||||
requires-python = ">=3.12,<3.14"
|
||||
dependencies = [
|
||||
"aio-pika==9.5.5",
|
||||
"grpcio==1.68.1",
|
||||
"grpcio-health-checking==1.68.1",
|
||||
"grpcio-reflection==1.68.1",
|
||||
"grpcio-tools==1.68.1",
|
||||
"httpx==0.28.1",
|
||||
"motor==3.6.0",
|
||||
"protobuf==5.29.2",
|
||||
"pydantic==2.10.4",
|
||||
"pydantic-settings==2.7.1",
|
||||
"pyjwt==2.10.1",
|
||||
"redis==5.2.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"mypy==1.14.1",
|
||||
"pytest==8.3.4",
|
||||
"pytest-asyncio==0.25.2",
|
||||
"ruff==0.9.1",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=75.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
exclude = ["src/gapido_auth/generated/*"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "B", "UP", "ASYNC"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
strict = true
|
||||
packages = ["gapido_auth"]
|
||||
exclude = ["src/gapido_auth/generated"]
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["gapido_auth.generated.*"]
|
||||
ignore_errors = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = [
|
||||
"grpc",
|
||||
"grpc.*",
|
||||
"grpc_tools",
|
||||
"grpc_tools.*",
|
||||
"grpc_health.*",
|
||||
"grpc_reflection.*",
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
2
src/gapido_auth/__init__.py
Normal file
2
src/gapido_auth/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Gapido authentication service."""
|
||||
|
||||
1
src/gapido_auth/py.typed
Normal file
1
src/gapido_auth/py.typed
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user