From b33716863be116376333ab7894d79b99a4a41e36 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Tue, 14 Jul 2026 09:32:38 +0330 Subject: [PATCH] chore: add project scaffold --- .env.example | 19 ++++++++++ .gitignore | 40 +++++++++++++++++++++ Dockerfile | 21 +++++++++++ Makefile | 20 +++++++++++ README.md | 54 +++++++++++++++++++++++++++++ docker-compose.yml | 68 ++++++++++++++++++++++++++++++++++++ pyproject.toml | 69 +++++++++++++++++++++++++++++++++++++ src/gapido_auth/__init__.py | 2 ++ src/gapido_auth/py.typed | 1 + 9 files changed, 294 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 pyproject.toml create mode 100644 src/gapido_auth/__init__.py create mode 100644 src/gapido_auth/py.typed diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a1757c3 --- /dev/null +++ b/.env.example @@ -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 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28481d6 --- /dev/null +++ b/.gitignore @@ -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/ + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bea9c7b --- /dev/null +++ b/Dockerfile @@ -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"] + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b8dba22 --- /dev/null +++ b/Makefile @@ -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 + diff --git a/README.md b/README.md new file mode 100644 index 0000000..61542ae --- /dev/null +++ b/README.md @@ -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 +``` + +## 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. + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..36b7839 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6b58a25 --- /dev/null +++ b/pyproject.toml @@ -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 diff --git a/src/gapido_auth/__init__.py b/src/gapido_auth/__init__.py new file mode 100644 index 0000000..d3e505c --- /dev/null +++ b/src/gapido_auth/__init__.py @@ -0,0 +1,2 @@ +"""Gapido authentication service.""" + diff --git a/src/gapido_auth/py.typed b/src/gapido_auth/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/gapido_auth/py.typed @@ -0,0 +1 @@ +