diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dcf26af --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.git/ +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +.venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +.env +.env.* +!.env.example +!.env.production.example +htmlcov/ +build/ +dist/ +mongo-data/ +redis-data/ +rabbitmq-data/ + diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..6f1b829 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,24 @@ +APP_ENV=production +MONGO_DB_NAME=gapido_auth +JWT_SECRET_KEY=replace-with-a-long-random-production-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 + +# Choose exactly one real provider in production: kavenegar or sms_ir. +SMS_PROVIDER=sms_ir +KAVENEGAR_API_KEY= +KAVENEGAR_LOGIN_TEMPLATE=login-otp +SMS_IR_API_KEY=replace-with-real-sms-ir-key +SMS_IR_VERIFY_TEMPLATE_ID=570574 + +ADMIN_MOBILE=989120000000 +DEMO_ENABLE_DEBUG_OTP=false +DEMO_DEBUG_SMS_TTL_SECONDS=300 +GRPC_HOST=0.0.0.0 +GRPC_PORT=50051 + diff --git a/.gitignore b/.gitignore index 28481d6..7fa7b70 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ htmlcov/ .env .env.* !.env.example +!.env.production.example # IDE and OS .idea/ @@ -37,4 +38,3 @@ Thumbs.db mongo-data/ redis-data/ rabbitmq-data/ - diff --git a/Dockerfile b/Dockerfile index bea9c7b..0039dac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,14 +8,16 @@ WORKDIR /app RUN apt-get update \ && apt-get install -y --no-install-recommends curl \ + && groupadd --system app \ + && useradd --system --gid app --home-dir /app app \ && 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 +RUN pip install --no-cache-dir "." + +USER app CMD ["python", "-m", "gapido_auth.transport.grpc.server"] - diff --git a/Makefile b/Makefile index b8dba22..31084d0 100644 --- a/Makefile +++ b/Makefile @@ -13,8 +13,10 @@ typecheck: mypy compose-up: - docker compose up --build + docker compose -f docker-compose.yml -f docker-compose.local.yml up --build compose-down: docker compose down --remove-orphans +compose-prod-config: + docker compose --env-file .env.production -f docker-compose.yml -f docker-compose.prod.yml config diff --git a/README.md b/README.md index 5a7086e..c05ebe0 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,29 @@ Python gRPC OTP authentication service with MongoDB, Redis, RabbitMQ, and select ```bash cp .env.example .env -docker compose up --build +docker compose -f docker-compose.yml -f docker-compose.local.yml up --build ``` 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`. +## Production Deployment + +Production uses Caddy as the only public entrypoint for `https://gapido.amiirkhl.ir`. + +```bash +cp .env.production.example .env.production +docker compose --env-file .env.production -f docker-compose.yml -f docker-compose.prod.yml up -d --build +``` + +Before running production, set real values in `.env.production`: + +- `JWT_SECRET_KEY` +- `ADMIN_MOBILE` +- `SMS_PROVIDER` +- the selected SMS provider credentials + +Only ports `80` and `443` are published in the production Compose overlay. MongoDB, Redis, RabbitMQ, gRPC, and the FastAPI demo service stay private on the Docker network. + ## SMS Provider The SMS integration uses the Strategy pattern behind the `SmsClient` port. Select the provider with: @@ -53,6 +71,8 @@ python -m gapido_auth.tools.generate_proto pytest ``` +The production Docker image installs runtime dependencies only. Development tools are installed locally through `.[dev]`. + ## gRPC Methods - `RequestOtp`: public; creates a short-lived OTP and publishes an SMS job. diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile new file mode 100644 index 0000000..6ba77b4 --- /dev/null +++ b/deploy/caddy/Caddyfile @@ -0,0 +1,15 @@ +gapido.amiirkhl.ir { + encode zstd gzip + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" + Permissions-Policy "camera=(), microphone=(), geolocation=()" + -Server + } + + reverse_proxy demo-app:8080 +} + diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..007fd5c --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,24 @@ +services: + auth-service: + ports: + - "50051:50051" + + demo-app: + ports: + - "8080:8080" + environment: + DEMO_ENABLE_DEBUG_OTP: "true" + + mongo: + ports: + - "27017:27017" + + redis: + ports: + - "6379:6379" + + rabbitmq: + ports: + - "5672:5672" + - "15672:15672" + diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..16b9c9a --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,44 @@ +services: + auth-service: + environment: + SMS_PROVIDER: ${SMS_PROVIDER:?set SMS_PROVIDER in production env} + JWT_SECRET_KEY: ${JWT_SECRET_KEY:?set JWT_SECRET_KEY in production env} + ADMIN_MOBILE: ${ADMIN_MOBILE:?set ADMIN_MOBILE in production env} + + sms-worker: + environment: + SMS_PROVIDER: ${SMS_PROVIDER:?set SMS_PROVIDER in production env} + KAVENEGAR_API_KEY: ${KAVENEGAR_API_KEY:-} + SMS_IR_API_KEY: ${SMS_IR_API_KEY:-} + + demo-app: + environment: + DEMO_ENABLE_DEBUG_OTP: "false" + depends_on: + auth-service: + condition: service_healthy + + caddy: + image: caddy:2.8-alpine + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + depends_on: + demo-app: + condition: service_healthy + healthcheck: + test: ["CMD", "caddy", "validate", "--config", "/etc/caddy/Caddyfile"] + interval: 30s + timeout: 5s + retries: 3 + +volumes: + caddy-data: + caddy-config: + diff --git a/docker-compose.yml b/docker-compose.yml index 8d9f8d6..2345bdf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,8 +21,18 @@ services: ADMIN_MOBILE: ${ADMIN_MOBILE:-989120000000} GRPC_HOST: 0.0.0.0 GRPC_PORT: 50051 - ports: - - "50051:50051" + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import grpc; from grpc_health.v1 import health_pb2, health_pb2_grpc; channel=grpc.insecure_channel('localhost:50051'); stub=health_pb2_grpc.HealthStub(channel); stub.Check(health_pb2.HealthCheckRequest(), timeout=3)", + ] + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s depends_on: mongo: condition: service_started @@ -51,32 +61,31 @@ services: environment: AUTH_GRPC_TARGET: auth-service:50051 REDIS_URL: redis://redis:6379/0 - DEMO_ENABLE_DEBUG_OTP: "true" - ports: - - "8080:8080" + DEMO_ENABLE_DEBUG_OTP: ${DEMO_ENABLE_DEBUG_OTP:-true} + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/healthz', timeout=3).read()"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 10s depends_on: auth-service: - condition: service_started + condition: service_healthy redis: condition: service_started 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" + volumes: + - rabbitmq-data:/var/lib/rabbitmq healthcheck: test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"] interval: 5s @@ -85,3 +94,4 @@ services: volumes: mongo-data: + rabbitmq-data: diff --git a/pyproject.toml b/pyproject.toml index f403fb8..f42cc06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,6 @@ dependencies = [ "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", @@ -22,6 +21,7 @@ dependencies = [ [project.optional-dependencies] dev = [ + "grpcio-tools==1.68.1", "mypy==1.14.1", "pytest==8.3.4", "pytest-asyncio==0.25.2", diff --git a/src/gapido_demo/app.py b/src/gapido_demo/app.py index f3e8a00..572b426 100644 --- a/src/gapido_demo/app.py +++ b/src/gapido_demo/app.py @@ -97,6 +97,11 @@ async def index() -> FileResponse: return FileResponse(STATIC_DIR / "index.html") +@app.get("/healthz") +async def healthz() -> dict[str, str]: + return {"status": "ok"} + + @app.post("/api/auth/request-otp") async def request_otp( body: RequestOtpBody, diff --git a/tests/test_demo_app.py b/tests/test_demo_app.py index f96cc26..995ad55 100644 --- a/tests/test_demo_app.py +++ b/tests/test_demo_app.py @@ -105,6 +105,16 @@ def test_demo_auth_flow_forwards_to_grpc_client() -> None: app.dependency_overrides.clear() +def test_healthz() -> None: + client = TestClient(app) + try: + response = client.get("/healthz") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + finally: + client.close() + + 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()