chore: add project scaffold

This commit is contained in:
2026-07-14 09:32:38 +03:30
commit b33716863b
9 changed files with 294 additions and 0 deletions

19
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View File

@@ -0,0 +1,2 @@
"""Gapido authentication service."""

1
src/gapido_auth/py.typed Normal file
View File

@@ -0,0 +1 @@