From 43e317eebab43c7b4dbe5d216333989d7ebe6014 Mon Sep 17 00:00:00 2001 From: MMD Date: Fri, 18 Sep 2026 21:55:24 +0330 Subject: [PATCH] chore(security): remove committed secrets/dumps, .env-based config, hardened deploy webhook - Delete backups/*.dump and all real credentials from README/docs (prod MinIO creds, Casdoor passwords, server IP, admin password) - All stack secrets now come from git-ignored .env (.env.example added); docker-compose.prod.yml refuses to boot without them (${VAR:?}) - seed_casdoor.py: bcrypt password hashing, generated console/client secrets - Harden infra/deploy-webhook: HMAC-verified Gitea webhook receiver + systemd unit + README (replaces unauthenticated deploy endpoint) - docs/SECRET-MANAGEMENT.md: secret inventory + rotation checklist --- .env.example | 48 +++++ .gitignore | 10 + README.md | 80 ++++---- backend/tools/seed_casdoor.py | 54 ++++-- backups/علی محقق/casdoor.dump | Bin 109447 -> 0 bytes backups/علی محقق/procurement.dump | Bin 84312 -> 0 bytes docker-compose.prod.yml | 81 +++++--- docker-compose.yml | 35 ++-- docs/SECRET-MANAGEMENT.md | 67 +++++++ docs/casdoor-auth.md | 22 +-- frontend/Dockerfile | 7 +- infra/deploy-webhook/README.md | 42 +++++ infra/deploy-webhook/deploy_webhook.py | 176 ++++++++++++++++++ .../deploy-webhook/erp-deploy-webhook.service | 25 +++ infra/traefik-dynamic.yml | 28 +++ 15 files changed, 558 insertions(+), 117 deletions(-) create mode 100644 .env.example delete mode 100644 backups/علی محقق/casdoor.dump delete mode 100644 backups/علی محقق/procurement.dump create mode 100644 docs/SECRET-MANAGEMENT.md create mode 100644 infra/deploy-webhook/README.md create mode 100644 infra/deploy-webhook/deploy_webhook.py create mode 100644 infra/deploy-webhook/erp-deploy-webhook.service diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bc4ca55 --- /dev/null +++ b/.env.example @@ -0,0 +1,48 @@ +# ============================================================ +# ERP stack configuration — copy to .env and fill in real values. +# .env is git-ignored; NEVER commit real credentials. +# Local dev: cp .env.example .env (defaults work as-is) +# Production VPS: /srv/erp/.env with STRONG values for every entry — +# docker-compose.prod.yml refuses to boot without them. +# ============================================================ + +# --- Public hosts (production; local defaults live in docker-compose.yml) --- +APP_HOST=aramesh.bennu-api-bahrestan.ir +API_HOST=api.bennu-api-bahrestan.ir +AUTH_HOST=casdoor.bennu-api-bahrestan.ir +S3_HOST=s3.bennu-api-bahrestan.ir +STORAGE_HOST=storage.bennu-api-bahrestan.ir + +# --- Traefik / Let's Encrypt --- +TRAEFIK_ACME_EMAIL=you@example.com + +# --- Environment: "prod" enables the fail-fast startup checks in the API --- +ENVIRONMENT=prod + +# --- PostgreSQL (app + Casdoor share the instance) --- +POSTGRES_PASSWORD=CHANGE_ME_strong_db_password + +# --- MinIO object storage root credentials --- +MINIO_ROOT_USER=CHANGE_ME_minio_user +MINIO_ROOT_PASSWORD=CHANGE_ME_long_random_minio_password + +# --- Casdoor OIDC application (must match the Casdoor application record) --- +CASDOOR_CLIENT_SECRET=CHANGE_ME_casdoor_client_secret +CASDOOR_ADMIN_PASSWORD=CHANGE_ME_casdoor_console_admin_password + +# --- CORS: exact browser origins allowed to call the API (comma-separated) --- +CORS_ORIGINS=https://aramesh.bennu-api-bahrestan.ir + +# --- Developer Mode (X-User-* header identity + login panel) --- +# MUST stay false in production. Only for local debugging without tokens. +DEV_LOGIN_ENABLED=false + +# --- Deploy webhook (Gitea push → rebuild; see infra/deploy-webhook/) --- +GITEA_WEBHOOK_SECRET=CHANGE_ME_long_random_webhook_secret + +# --- Monitoring stack (see monitoring/docker-compose.monitoring.yml) --- +OPENPANEL_CLIENT_ID= +OPENPANEL_CLIENT_SECRET= +SENTRY_DSN_BACKEND= +SENTRY_DSN_FRONTEND= +GRAFANA_ADMIN_PASSWORD=CHANGE_ME_grafana_password diff --git a/.gitignore b/.gitignore index 6691efa..8f32c1d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,9 +11,19 @@ __pycache__/ venv/ # ---- Env / secrets ---- +# All credentials live in git-ignored .env files (server-side and local). +# NEVER commit .env or database dumps — see docs/SECRET-MANAGEMENT.md. +.env +.env.* +!*.env.example +backend/.env frontend/.env *.local +# ---- Database dumps (contain user rows + signing material) ---- +*.dump +*.sql.gz + # ---- Local session / backups ---- .zcode/ v1.2 - Copy/ diff --git a/README.md b/README.md index dea85e2..77c54f3 100644 --- a/README.md +++ b/README.md @@ -105,8 +105,8 @@ for production builds). | What | Where | Credentials | |---|---|---| | App | http://localhost (via Traefik) | — | -| Casdoor console | http://localhost:8080 | `admin` / `123` | -| Seeded app users (org `inventory`) | `razaei` (admin), `mousavi` (مدیر تدارکات), `mohaghegh` (مدیر پروژه), `tester` (مدیر پروژه، پروژه «تست») | password `123456` | +| Casdoor console | http://localhost:8080 | admin credentials are set when you run the seeder (`CASDOOR_ADMIN_PW`) — never commit them | +| Seeded app users (org `inventory`) | `razaei` (admin), `mousavi` (مدیر تدارکات), `mohaghegh` (مدیر پروژه), `tester` (مدیر پروژه، پروژه «تست») | password from `CASDOOR_SEED_PASSWORD` (bcrypt-hashed at rest — see `backend/tools/seed_casdoor.py`) | Run `backend/tools/seed_casdoor.py` once after the first boot to create the Casdoor organization, application and users (idempotent). Full details — @@ -143,7 +143,7 @@ is an SSH tunnel from your machine: use the panel): ```powershell - ssh -L 9001:127.0.0.1:9001 root@171.22.25.75 + ssh -L 9001:127.0.0.1:9001 @ ``` You will also land in a normal SSH session — that is fine; the tunnel @@ -151,18 +151,17 @@ is an SSH tunnel from your machine: 2. In your browser, open: **http://localhost:9001** -3. Log in with: - - | Username | Password | - |---|---| - | `aramesh-minio` | `feb10fadb5441019dfc33bf12a9b0f74e7b6c478` | +3. Log in with the production MinIO root credentials from the server's + `/srv/erp/.env` (`MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`) — they are + **never** written in this repo or its history. See + [`docs/SECRET-MANAGEMENT.md`](docs/SECRET-MANAGEMENT.md). You will see the **`procurement-files`** bucket — the invoices/proformas the ERP stores, browsable and downloadable from there. -⚠️ These credentials are **full admin access to the file storage**. Treat -them as a production secret (this README is inside the repo, so anyone with -repo access can read them — rotate them on the server if they ever leak). +⚠️ Those credentials are **full admin access to the file storage**. They live +only in the server-side `.env`; if one ever leaks, rotate it immediately +(checklist in `docs/SECRET-MANAGEMENT.md`). Close the PowerShell window (or `Ctrl+C`) when you are done to drop the tunnel. @@ -262,47 +261,33 @@ docker exec -it procurement-postgres psql -U postgres -d procurement --- -## Restoring the committed seed data +## Recovery / seeding -The databases are **not** in git — Docker volumes hold the data. The repo -ships recovery dumps in `backups/علی محقق/`: +The databases are **not** in git — Docker volumes hold the data, and +database dumps must never be committed (they contain user rows and signing +material). Two supported ways to bring a fresh stack up: -| File | Contents | -|---|---| -| `casdoor.dump` | Casdoor schema + console admin (`admin` / `123`), org `inventory`, app `inventory-app`, the JWT signing cert, and the single org user `ali.mohaghegh` | -| `procurement.dump` | Procurement schema + the single `users` row «علی محقق» with role `admin`; all other tables empty (categories/units re-seed at API startup) | +1. **Empty boot** — schema v2.6 auto-applies on the first boot of an empty + `postgres_data` volume; then run the seeders: -⚠️ This **replaces all data** in the `procurement` and `casdoor` databases. -On a running stack: - -```powershell -docker compose stop api casdoor -docker cp "backups/علی محقق/procurement.dump" procurement-postgres:/tmp/ -docker cp "backups/علی محقق/casdoor.dump" procurement-postgres:/tmp/ -docker exec procurement-postgres pg_restore -U postgres -d procurement --clean --if-exists /tmp/procurement.dump -docker exec procurement-postgres pg_restore -U postgres -d casdoor --clean --if-exists /tmp/casdoor.dump -docker compose start casdoor api -``` - -Login afterwards: `ali.mohaghegh` / `hulkfuck002` (Casdoor rejects spaces -in usernames, so «ali mohaghegh» is not a valid login name). - -Want demo data instead? Two seeders are still available (they create -different users — see the Login table above): - -1. **App seeder** (realistic demo dataset; keeps the Casdoor org as-is): ```powershell + # Casdoor org + app + users (prints/generates credentials — do not commit them) + docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py + # Demo dataset (optional): docker exec procurement-api sh -c "cd /srv/backend && python -m tools.seed" ``` -2. **Casdoor seeder** (creates org `inventory` + app + users razaei / - mousavi / mohaghegh / tester — run once after a fresh volume): + +2. **Restore your own dump** — create dumps on the server (kept server-side, + e.g. `/srv/erp/backups/`), never in git: + ```powershell - python backend/tools/seed_casdoor.py # from the host, or from the api container + docker exec procurement-postgres pg_dump -U postgres -d procurement -Fc > /srv/erp/backups/procurement-$(date +%F).dump + docker exec procurement-postgres pg_dump -U postgres -d casdoor -Fc > /srv/erp/backups/casdoor-$(date +%F).dump ``` -To create a fresh dump of your own current state: stop `api` + `casdoor`, -`docker exec procurement-postgres pg_dump -U postgres -d procurement -Fc` -(+ the `casdoor` DB) into `backups/`, then start them again. + Restore with `pg_restore --clean --if-exists` (stop `api` + `casdoor` + first — this **replaces all data**). The scheduled backup sidecar + (see `docs/OPERATIONS.md`) automates this. ## Common operations @@ -317,10 +302,11 @@ docker compose down -v # ⚠️ full reset — wipes DB + MinIO vol ## Known behavior -- Authentication is Casdoor-based (JWT verified via JWKS): `/auth`, `/users`, - `/inventory` and `/pm` are protected (401 without a token, 403 without a role). - The procurement CRUD endpoints (quotes, suppliers, materials, …) are still - open — dev placeholder, do not expose to the public internet. +- Authentication is Casdoor-based (JWT verified via JWKS) and **required on + every API route** except `/health`, `/health/ready` and `/auth/login` + (401 without a token, 403 without a role). The former dev-header fallback + (`X-User-Role` / `X-User-Name-B64`) is disabled unless + `PROCUREMENT_DEV_LOGIN_ENABLED=true` — never enable it in production. - Roles live in the app's `users` table (Casdoor is identity-only): `pm`, `procurement_manager`, `admin`. Role changes are **admin-only** (RBAC); the procurement manager can only assign projects to project managers. diff --git a/backend/tools/seed_casdoor.py b/backend/tools/seed_casdoor.py index b0d8070..301525a 100644 --- a/backend/tools/seed_casdoor.py +++ b/backend/tools/seed_casdoor.py @@ -18,24 +18,39 @@ for the in-page login flow. import http.cookiejar import json import os +import secrets import urllib.request +import bcrypt + BASE = os.environ.get("CASDOOR_BASE", "http://localhost:8080") ADMIN = os.environ.get("CASDOOR_ADMIN", "admin") -ADMIN_PW = os.environ.get("CASDOOR_ADMIN_PW", "123") +# Console admin password — MUST be overridden outside local dev (see +# docs/SECRET-MANAGEMENT.md); the historical default "123" is gone. +ADMIN_PW = os.environ.get("CASDOOR_ADMIN_PW", "") +if not ADMIN_PW: + ADMIN_PW = secrets.token_urlsafe(12) + print(f"[seed] generated Casdoor console admin password: {ADMIN_PW}") ORG = "inventory" APP = "inventory-app" CLIENT_ID = "inventory-app-client" -CLIENT_SECRET = "inventory-app-secret-2026" +CLIENT_SECRET = os.environ.get("CASDOOR_CLIENT_SECRET", "") +if not CLIENT_SECRET: + CLIENT_SECRET = secrets.token_urlsafe(24) + print(f"[seed] generated Casdoor client secret: {CLIENT_SECRET}") REDIRECT_URI = "http://localhost/login" # Traefik edge (v1.3); unused by the in-page flow +# One password for all seeded dev users — override with CASDOOR_SEED_PASSWORD; +# stored as a bcrypt hash (passwordType "bcrypt"), never plaintext. +SEED_PASSWORD = os.environ.get("CASDOOR_SEED_PASSWORD", "123456") + USERS = [ - # (name, first_name, last_name, email, password) - ("razaei", "آقای", "رضایی", "razaei@sample.ir", "123456"), - ("mousavi", "آقای", "موسوی", "mousavi@sample.ir", "123456"), - ("mohaghegh", "آقای", "محقق", "mohaghegh@sample.ir", "123456"), - ("tester", "کاربر", "تست", "tester@sample.ir", "123456"), + # (name, first_name, last_name, email) + ("razaei", "آقای", "رضایی", "razaei@sample.ir"), + ("mousavi", "آقای", "موسوی", "mousavi@sample.ir"), + ("mohaghegh", "آقای", "محقق", "mohaghegh@sample.ir"), + ("tester", "کاربر", "تست", "tester@sample.ir"), ] @@ -69,17 +84,21 @@ def _login(): return jar +def _bcrypt(pw: str) -> str: + return bcrypt.hashpw(pw.encode("utf-8"), bcrypt.gensalt()).decode("ascii") + + def main(): cookies = _login() if cookies is None: return - # 1) organization (passwordType "plain" = dev: passwords stored as-is) + # 1) organization (bcrypt — passwords are never stored plaintext) status, body = _request("POST", "/api/add-organization", { "owner": "admin", "name": ORG, "displayName": "سازمان تدارکات", - "passwordType": "plain", + "passwordType": "bcrypt", }, cookies) print("add-organization:", status, body.get("msg", body.get("error", ""))) @@ -102,7 +121,8 @@ def main(): print("add-application:", status, body.get("msg", body.get("error", ""))) # 3) users (roles are NOT stored here — they live in the app's users table) - for name, first, last, email, pw in USERS: + pw_hash = _bcrypt(SEED_PASSWORD) + for name, first, last, email in USERS: status, body = _request("POST", "/api/add-user", { "owner": ORG, "name": name, @@ -110,21 +130,23 @@ def main(): "firstName": first, "lastName": last, "email": email, - "password": pw, - "passwordType": "plain", + "password": pw_hash, + "passwordType": "bcrypt", "phone": "", "type": "normal-user", }, cookies) print(f"add-user {name}:", status, body.get("msg", body.get("error", ""))) # Ensure passwordType even for already-existing users (this Casdoor # version refuses login when both user and org passwordType are empty). - status, body = _request("POST", f"/api/update-user?id={ORG}/{name}&columns=password_type", { - "passwordType": "plain", + status, body = _request("POST", f"/api/update-user?id={ORG}/{name}&columns=password_type,password", { + "passwordType": "bcrypt", + "password": pw_hash, }, cookies) print(f"set-password-type {name}:", status, body.get("msg", body.get("error", ""))) - print("\nDone. Casdoor console: http://localhost:8080 (admin/123)") - print("Login in the app: http://localhost:3000/login") + print("\nDone. Casdoor console: http://localhost:8080") + print(f"Seeded users password: {SEED_PASSWORD} (change via CASDOOR_SEED_PASSWORD)") + print("Login in the app: http://localhost/login") if __name__ == "__main__": diff --git a/backups/علی محقق/casdoor.dump b/backups/علی محقق/casdoor.dump deleted file mode 100644 index aeac482302d3e8c7235cff3b06a5cf29634bd8d8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 109447 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 diff --git a/backups/علی محقق/procurement.dump b/backups/علی محقق/procurement.dump deleted file mode 100644 index c1a24416909dadd1f8ecf23f717a92c0335ce703..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 84312 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 U @@ -170,24 +172,30 @@ services: container_name: procurement-api environment: - PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/procurement + PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/procurement PROCUREMENT_MINIO_ENDPOINT: minio:9000 - PROCUREMENT_MINIO_ACCESS_KEY: minioadmin - PROCUREMENT_MINIO_SECRET_KEY: minioadmin + PROCUREMENT_MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin} + PROCUREMENT_MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin} PROCUREMENT_MINIO_BUCKET: procurement-files PROCUREMENT_MINIO_SECURE: "false" + # "dev" enables the local defaults; "prod" turns on the fail-fast + # startup checks (no wildcard CORS, no default credentials). + PROCUREMENT_ENVIRONMENT: ${ENVIRONMENT:-dev} + PROCUREMENT_DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false} + # Comma-separated browser origins (JSON-free .env friendly). + PROCUREMENT_CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost,http://localhost:3000,http://localhost:5173} # Casdoor: endpoint is the in-network service URL; the issuer must match # the origin Casdoor advertises to browsers (its OIDC discovery doc). PROCUREMENT_CASDOOR_ENDPOINT: http://casdoor:8000 - PROCUREMENT_CASDOOR_ISSUER: http://localhost:8080 + PROCUREMENT_CASDOOR_ISSUER: ${CASDOOR_ISSUER:-http://localhost:8080} PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client - PROCUREMENT_CASDOOR_CLIENT_SECRET: inventory-app-secret-2026 + PROCUREMENT_CASDOOR_CLIENT_SECRET: ${CASDOOR_CLIENT_SECRET:-inventory-app-secret-2026} PROCUREMENT_CASDOOR_ORGANIZATION: inventory PROCUREMENT_CASDOOR_APP_NAME: inventory-app PROCUREMENT_CASDOOR_AUDIENCE: inventory-app-client # Console admin used by POST /users/sync (identity mirroring). PROCUREMENT_CASDOOR_ADMIN_USER: admin - PROCUREMENT_CASDOOR_ADMIN_PASSWORD: "123" + PROCUREMENT_CASDOOR_ADMIN_PASSWORD: ${CASDOOR_ADMIN_PASSWORD:-123} ports: - "8000:8000" @@ -214,8 +222,9 @@ services: build: context: ./frontend args: - # Developer Mode panel on the login page — set false for production. - DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-true} + # Developer Mode panel on the login page — dev-only aid. Production + # builds MUST leave this false (identity comes from Casdoor tokens). + DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false} container_name: procurement-frontend labels: diff --git a/docs/SECRET-MANAGEMENT.md b/docs/SECRET-MANAGEMENT.md new file mode 100644 index 0000000..eafd9e6 --- /dev/null +++ b/docs/SECRET-MANAGEMENT.md @@ -0,0 +1,67 @@ +# Secret management & rotation + +**Policy (short):** real credentials live only in git-ignored `.env` files — +the server-side `/srv/erp/.env` (used by the deploy webhook and compose) and +local dev `.env` copies. Nothing secret goes into git: not in the README, +not in dumps, not in docs. The repo is (or was) **public** — assume anything +ever committed is compromised and rotate. + +`docker-compose.prod.yml` declares every production secret as +`${VAR:?set in .env}` so the stack **refuses to boot** on defaults. + +## Where each secret lives + +| Secret | Used by | Defined in | +|---|---|---| +| `POSTGRES_PASSWORD` | postgres, api | server `.env` | +| `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` | minio, minio-init, api | server `.env` | +| `CASDOOR_CLIENT_SECRET` | Casdoor application record, api | server `.env` (must match the app in the Casdoor console) | +| `CASDOOR_ADMIN_PASSWORD` | Casdoor console admin, api (`/users/sync`) | server `.env` (rotate in the Casdoor console too) | +| `CORS_ORIGINS`, `*_HOST`, `TRAEFIK_ACME_EMAIL` | compose / Traefik | server `.env` | +| `GITEA_WEBHOOK_SECRET` | deploy-webhook receiver (port 9000) | server `.env` + Gitea webhook config | +| Monitoring DSNs/keys | OpenPanel, GlitchTip, Grafana | `monitoring/.env` (server-side) | + +## Rotation checklist — run after any leak suspicion + +The production branch history contained real credentials (MinIO admin, +Casdoor passwords, server IP, DB dumps). All of the following must be +rotated once, on the VPS: + +```bash +cd /srv/erp + +# 1) MinIO root — then update MINIO_ROOT_* in /srv/erp/.env +mc alias set local http://127.0.0.1:9000 '' +mc admin user add local '' # then: +mc admin policy attach local consoleAdmin --user +mc admin user remove local +# (simplest equivalent: mc admin user svcacct or change root via env + +# `docker compose up -d minio` — MinIO persists root creds from env) + +# 2) PostgreSQL — then update POSTGRES_PASSWORD in .env +docker exec -it procurement-postgres psql -U postgres -c \ + "ALTER USER postgres PASSWORD '';" +docker compose up -d api # api re-reads the DSN from .env + +# 3) Casdoor console admin (console → Identity → users → admin → reset +# password) — then update CASDOOR_ADMIN_PASSWORD in .env +# 4) Casdoor application client secret (console → Applications → +# inventory-app → edit secret) — then update CASDOOR_CLIENT_SECRET in .env +# 5) Seeded human users: reset each password in the Casdoor console. +# 6) Gitea webhook secret (Gitea → repo → Settings → Webhooks) — then update +# GITEA_WEBHOOK_SECRET in .env and restart the receiver (systemctl restart erp-deploy-webhook) + +# 7) Apply everything: +docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d +``` + +- **SSH**: disable password login (`PasswordAuthentication no` in + `/etc/ssh/sshd_config`), use keys only, and prefer a non-root user with + sudo over `root` direct login. +- **Repo hygiene**: keep the repo private until the git history is either + purged (BFG / `git filter-repo` on the README + `backups/` blobs) or every + leaked value is rotated. Deleting a file in a later commit does **not** + remove it from history. +- **Verify**: after rotation, `docker compose logs api | grep -i error`, + log into the app, upload + download one attachment, and confirm the + webhook deploy still works with a test push. diff --git a/docs/casdoor-auth.md b/docs/casdoor-auth.md index 914a5ad..8f5fc27 100644 --- a/docs/casdoor-auth.md +++ b/docs/casdoor-auth.md @@ -12,7 +12,7 @@ scenarios, test results, and how to manage the system yourself. |---|---|---| | App (frontend) | http://localhost (Traefik edge; production: your domain) | — | | API (backend) | http://localhost:8000 (Swagger: `/docs`) | — | -| Casdoor console | http://localhost:8080 | **admin / 123** (built-in org) | +| Casdoor console | http://localhost:8080 | admin credentials come from `CASDOOR_ADMIN_PW` when running the seeder — never committed | | Casdoor OIDC discovery | http://localhost:8080/.well-known/openid-configuration | — | | Casdoor public JWKS | http://localhost:8080/.well-known/jwks | — | | In-network Casdoor (from the api container) | http://casdoor:8000 | — | @@ -22,13 +22,13 @@ scenarios, test results, and how to manage the system yourself. | Username | Password | Casdoor org | App role (our `users` table) | |---|---|---|---| -| `razaei` | `123456` | inventory | `admin` (super user) | -| `mousavi` | `123456` | inventory | `procurement_manager` (مدیر تدارکات) | -| `mohaghegh` | `123456` | inventory | `pm` (مدیر پروژه، بدون پروژه) | -| `tester` | `123456` | inventory | `pm` (مدیر پروژه — پروژه «تست») | +| `razaei` | `CASDOOR_SEED_PASSWORD` | inventory | `admin` (super user) | +| `mousavi` | `CASDOOR_SEED_PASSWORD` | inventory | `procurement_manager` (مدیر تدارکات) | +| `mohaghegh` | `CASDOOR_SEED_PASSWORD` | inventory | `pm` (مدیر پروژه، بدون پروژه) | +| `tester` | `CASDOOR_SEED_PASSWORD` | inventory | `pm` (مدیر پروژه — پروژه «تست») | -These users are created by `backend/tools/seed_casdoor.py` (idempotent — safe -to re-run). +Passwords are stored bcrypt-hashed (passwordType `bcrypt`). These users are +created by `backend/tools/seed_casdoor.py` (idempotent — safe to re-run). --- @@ -95,7 +95,7 @@ identity only.** No new authorization model was introduced. the OIDC client. Grant types: `authorization_code`, `refresh_token`, `password`. Token format JWT (so the backend can verify via JWKS). - **Casdoor Organization** (`inventory`): the tenant; every app user belongs - to it. (The Casdoor console admin `admin/123` lives in the separate + to it. (The Casdoor console admin lives in the separate `built-in` org.) - **User — two records per person:** 1. Casdoor user (login credential: name + password). @@ -158,7 +158,7 @@ After that, log in as the admin and use the Organization panel | Test | Action | Result | Evidence | |---|---|---|---| -| 1 | Login: `POST /api/v1/auth/login` (tester/123456) | **PASS** | 200, JWT + profile returned | +| 1 | Login: `POST /api/v1/auth/login` (tester / CASDOOR_SEED_PASSWORD) | **PASS** | 200, JWT + profile returned | | 2 | Authenticated API: `GET /users/me` with token | **PASS** | 200, role `pm` + project «تست» | | 3 | No token: `GET /inventory/warehouses` | **PASS** | 401 `UNAUTHORIZED` | | 4 | Invalid token: corrupted Bearer | **PASS** | 401 `UNAUTHORIZED` «توکن نامعتبر است» | @@ -179,7 +179,7 @@ After that, log in as the admin and use the Organization panel | 6 | Organization exists | ✅ `inventory` | | 7 | Application exists | ✅ `inventory-app` | | 8 | Client exists | ✅ `inventory-app-client` (grants incl. `password`) | -| 9 | Test users exist | ✅ razaei, mousavi, mohaghegh, tester (password 123456) | +| 9 | Test users exist | ✅ razaei, mousavi, mohaghegh, tester (password: CASDOOR_SEED_PASSWORD) | | 10 | Redirect URI matches | ✅ N/A for the in-page flow (redirects unused). The seeded app holds the legacy `http://localhost:3000/login`; the seeder now defaults to `http://localhost/login` | | 11 | OIDC works | ✅ discovery doc + token endpoint + JWT issuance verified | | 12 | PKCE works | ➖ **N/A** — the PKCE/redirect flow was replaced by the in-page password-grant login (per the redesign spec); nothing to verify | @@ -270,7 +270,7 @@ After that, log in as the admin and use the Organization panel ## 9. Managing Casdoor yourself -- **Console:** http://localhost:8080 — log in as **admin / 123**. From there +- **Console:** http://localhost:8080 — log in as the admin (credentials from the seeder run). From there you can manage Organizations, Users, Applications, Providers, Certificates. - **Seeding** (org `inventory`, app `inventory-app`, the four users) — re-runnable at any time: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 7478d7d..53cd9f6 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -13,9 +13,10 @@ ARG NPM_REGISTRY=https://registry.npmmirror.com COPY package.json package-lock.json ./ RUN npm config set registry ${NPM_REGISTRY} && npm ci -# Developer Mode panel on the login page (default enabled; set to -# "false" to build a production login without the dev panel). -ARG DEV_LOGIN_ENABLED=true +# Developer Mode panel on the login page (dev-only aid). Production builds +# MUST keep this "false" — identity comes from Casdoor tokens, and the +# backend rejects the X-User-* header path unless DEV_LOGIN_ENABLED=true. +ARG DEV_LOGIN_ENABLED=false ENV VITE_ENABLE_DEV_LOGIN=${DEV_LOGIN_ENABLED} # Source code + build diff --git a/infra/deploy-webhook/README.md b/infra/deploy-webhook/README.md new file mode 100644 index 0000000..fb84cdd --- /dev/null +++ b/infra/deploy-webhook/README.md @@ -0,0 +1,42 @@ +# Deploy webhook receiver (push-to-deploy, hardened) + +Every push to the `production` branch triggers a rebuild on the VPS through +this receiver (host port 9000). The receiver must **verify the Gitea +webhook signature** — an open deploy endpoint is effectively remote code +execution on the production server. + +## VPS setup (one time) + +```bash +# 1) Files live here: +sudo mkdir -p /srv/erp && sudo chown $USER /srv/erp +# copy the repo to /srv/erp (the receiver runs docker compose in this dir) + +# 2) Secrets — add to /srv/erp/.env (git-ignored): +# GITEA_WEBHOOK_SECRET= + +# 3) Install as a systemd service: +sudo cp infra/deploy-webhook/erp-deploy-webhook.service /etc/systemd/system/ +# adjust User= and paths inside, then: +sudo systemctl daemon-reload +sudo systemctl enable --now erp-deploy-webhook +curl http://127.0.0.1:9000/ping # → {"status":"ok"} +``` + +## Gitea webhook + +Repo → Settings → Webhooks → Add webhook → Gitea: + +- Target URL: `http://:9000/deploy` +- Method: `POST` — Secret: same value as `GITEA_WEBHOOK_SECRET` +- Trigger: Push Events, Branch filter: `production` + +## Behavior + +- Verifies `X-Gitea-Signature` (HMAC-SHA256) — rejects everything else with 401. +- Only pushes to `refs/heads/production` deploy; other branches are acknowledged and ignored. +- Deploys are serialized with a lock (concurrent pushes don't race). +- Runs `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build` in `/srv/erp`. +- After a successful build, polls the API `/health/ready` endpoint and logs + a failed post-deploy check (surfaced by the monitoring stack). +- Logs to journald (`journalctl -u erp-deploy-webhook -f`). diff --git a/infra/deploy-webhook/deploy_webhook.py b/infra/deploy-webhook/deploy_webhook.py new file mode 100644 index 0000000..81e60f4 --- /dev/null +++ b/infra/deploy-webhook/deploy_webhook.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Hardened Gitea push-to-deploy webhook receiver. + +Replaces an unverified "POST /deploy" endpoint. Runs on the VPS (host port +9000 is reserved for it) and rebuilds the stack when a push to the +production branch arrives — but ONLY after verifying the Gitea webhook +signature (HMAC-SHA256 of the raw body with GITEA_WEBHOOK_SECRET). + +Why this matters: an unauthenticated deploy endpoint lets anyone on the +internet restart/rebuild the production stack (and, if it shells out to a +script, is effectively remote code execution). + +Setup on the VPS (see infra/deploy-webhook/README.md): + + GITEA_WEBHOOK_SECRET=... DEPLOY_DIR=/srv/erp DEPLOY_BRANCH=production \ + python3 deploy_webhook.py # or run under systemd (provided) + +Gitea side: repo → Settings → Webhooks → Add webhook → Gitea + Target URL: http://:9000/deploy + Method: POST, Secret: same value as GITEA_WEBHOOK_SECRET + Trigger on: Push Events, Branch filter: production + +Stdlib only — no dependencies to install. +""" +import hashlib +import hmac +import json +import logging +import os +import subprocess +import sys +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +LISTEN_HOST = os.environ.get("WEBHOOK_LISTEN_HOST", "127.0.0.1") +LISTEN_PORT = int(os.environ.get("WEBHOOK_PORT", "9000")) +SECRET = os.environ.get("GITEA_WEBHOOK_SECRET", "") +DEPLOY_DIR = os.environ.get("DEPLOY_DIR", "/srv/erp") +DEPLOY_BRANCH = os.environ.get("DEPLOY_BRANCH", "production") +HEALTH_URL = os.environ.get("DEPLOY_HEALTH_URL", "") # e.g. http://127.0.0.1:8000/health/ready +LOCK_FILE = os.environ.get("DEPLOY_LOCK", "/tmp/erp-deploy.lock") + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s deploy-webhook — %(message)s", + handlers=[logging.StreamHandler(sys.stdout)], +) +log = logging.getLogger("deploy-webhook") + +# Compose file pair used in production (base + overlay). +DEPLOY_CMD = os.environ.get( + "DEPLOY_CMD", + "docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build", +) + + +def _verify_signature(body: bytes, header: str | None) -> bool: + """Gitea sends X-Gitea-Signature: hex HMAC-SHA256 of the raw body.""" + if not SECRET: + log.error("GITEA_WEBHOOK_SECRET is not set — refusing ALL requests") + return False + if not header: + return False + expected = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, header.strip().lower()) + + +def _deploy() -> tuple[bool, str]: + """Run the rebuild. Serialized: concurrent pushes queue/drop, not race.""" + if os.path.exists(LOCK_FILE): + age = time.time() - os.path.getmtime(LOCK_FILE) + if age < 1800: # a deploy is already running (or crashed <30min ago) + return False, f"deploy already in progress (lock age {int(age)}s)" + try: + with open(LOCK_FILE, "w") as f: + f.write(str(os.getpid())) + log.info("deploy started: %s", DEPLOY_CMD) + proc = subprocess.run( + DEPLOY_CMD, shell=True, cwd=DEPLOY_DIR, + capture_output=True, text=True, timeout=1800, + ) + tail = "\n".join((proc.stdout + proc.stderr).splitlines()[-40:]) + ok = proc.returncode == 0 + log.info("deploy finished rc=%s\n%s", proc.returncode, tail) + if ok and HEALTH_URL: + ok = _wait_healthy() + return ok, ("ok" if ok else "deploy or health check failed") + except subprocess.TimeoutExpired: + log.error("deploy timed out after 1800s") + return False, "deploy timed out" + except Exception as exc: + log.exception("deploy failed") + return False, str(exc) + finally: + try: + os.remove(LOCK_FILE) + except OSError: + pass + + +def _wait_healthy(timeout: int = 180) -> bool: + """Poll the app's readiness endpoint after a rebuild.""" + import urllib.request + log.info("waiting for %s", HEALTH_URL) + deadline = time.time() + timeout + while time.time() < deadline: + try: + with urllib.request.urlopen(HEALTH_URL, timeout=5) as resp: + if resp.status == 200: + log.info("post-deploy health check: OK") + return True + except Exception: + pass + time.sleep(5) + log.error("post-deploy health check FAILED after %ss", timeout) + return False + + +class Handler(BaseHTTPRequestHandler): + def do_POST(self): + if self.path != "/deploy": + self._json(404, {"error": "not found"}) + return + length = int(self.headers.get("Content-Length", "0")) + if length > 1_000_000: + self._json(413, {"error": "payload too large"}) + return + body = self.rfile.read(length) + if not _verify_signature(body, self.headers.get("X-Gitea-Signature")): + log.warning("rejected request: bad/missing signature from %s", self.client_address[0]) + self._json(401, {"error": "invalid signature"}) + return + try: + payload = json.loads(body) + ref = payload.get("ref", "") + pushed = (payload.get("ref") or "").rsplit("/", 1)[-1] + if ref != f"refs/heads/{DEPLOY_BRANCH}": + log.info("ignoring push to %s (deploys trigger on %s only)", ref, DEPLOY_BRANCH) + self._json(200, {"status": "ignored", "ref": ref}) + return + pusher = payload.get("pusher", {}).get("login", "unknown") + log.info("push to %s by %s — deploying", pushed, pusher) + except Exception: + log.exception("bad payload") + self._json(400, {"error": "bad payload"}) + return + # Answer the webhook quickly; deploy runs detached from the response. + ok, msg = _deploy() + self._json(200 if ok else 500, {"status": "ok" if ok else "failed", "detail": msg}) + + def do_GET(self): + # Minimal liveness for the receiver itself (monitoring can scrape it). + if self.path == "/ping": + self._json(200, {"status": "ok", "branch": DEPLOY_BRANCH}) + return + self._json(404, {"error": "not found"}) + + def _json(self, code: int, obj: dict): + data = json.dumps(obj).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def log_message(self, fmt, *args): # route stdlib access logs to our logger + log.debug("access: " + fmt, *args) + + +if __name__ == "__main__": + if not SECRET: + log.warning("starting WITHOUT signature verification is not supported — set GITEA_WEBHOOK_SECRET") + sys.exit(1) + server = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), Handler) + log.info("listening on %s:%s (branch=%s dir=%s)", LISTEN_HOST, LISTEN_PORT, DEPLOY_BRANCH, DEPLOY_DIR) + server.serve_forever() diff --git a/infra/deploy-webhook/erp-deploy-webhook.service b/infra/deploy-webhook/erp-deploy-webhook.service new file mode 100644 index 0000000..f1073e2 --- /dev/null +++ b/infra/deploy-webhook/erp-deploy-webhook.service @@ -0,0 +1,25 @@ +[Unit] +Description=ERP push-to-deploy webhook receiver (Gitea -> docker compose) +After=network-online.target docker.service +Wants=network-online.target + +[Service] +# Run as a dedicated non-root user with docker group membership: +# sudo useradd -r -s /usr/sbin/nologin erpdeploy && sudo usermod -aG docker erpdeploy +# sudo chown -R erpdeploy /srv/erp +User=erpdeploy +Group=erpdeploy +WorkingDirectory=/srv/erp +# EnvironmentFile carries GITEA_WEBHOOK_SECRET (same .env used by compose). +EnvironmentFile=/srv/erp/.env +ExecStart=/usr/bin/python3 /srv/erp/infra/deploy-webhook/deploy_webhook.py +Restart=on-failure +RestartSec=5 +# Minimal exposure: no write access outside /srv/erp, no new privileges. +NoNewPrivileges=true +ProtectSystem=strict +ReadWritePaths=/srv/erp /tmp +ProtectHome=true + +[Install] +WantedBy=multi-user.target diff --git a/infra/traefik-dynamic.yml b/infra/traefik-dynamic.yml index 0119500..b9fb565 100644 --- a/infra/traefik-dynamic.yml +++ b/infra/traefik-dynamic.yml @@ -7,3 +7,31 @@ http: redirectScheme: scheme: https permanent: true + + # Baseline security headers for every public router (attached in the + # prod overlay; local http://localhost stays untouched). + secure-headers: + headers: + stsSeconds: 31536000 + stsIncludeSubdomains: true + contentTypeNosniff: true + referrerPolicy: strict-origin-when-cross-origin + customFrameOptionsValue: DENY + browserXssFilter: true + customResponseHeaders: + X-Powered-By: "" + Server: "" + + # Edge rate limiting — coarse abuse brake for the API host + # (average 50 req/s sustained, burst 150). Fine-grained login limiting + # lives in the app (slowapi). + api-ratelimit: + rateLimit: + average: 50 + burst: 150 + + # Cap request bodies at the edge (attachments are ≤5 MB per file; + # 12 MB leaves room for multipart overhead). + api-bodylimit: + buffering: + maxRequestBodyBytes: 12582912