# Casdoor Authentication & Authorization Guide (v2.6) This document explains how the procurement + inventory system uses Casdoor: addresses, credentials, the login flow, the authorization model, failure scenarios, test results, and how to manage the system yourself. --- ## 1. Addresses & credentials | What | Address | Credentials | |---|---|---| | 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 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 | — | | Traefik host routes (local) | `localhost` → app · `api.localhost` → api · `auth.localhost` → casdoor · `s3.localhost` / `storage.localhost` → MinIO | needs those hosts to resolve (or use the host ports) | ### Seeded application users (org `inventory`) | 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` (مدیر پروژه — پروژه «تست») | These users are created by `backend/tools/seed_casdoor.py` (idempotent — safe to re-run). --- ## 2. Login flow (in-page, no redirect) ``` Browser ──► POST /api/v1/auth/login {username, password, organization?} │ ▼ Backend ──► POST http://casdoor:8000/api/login/oauth/access_token grant_type=password, client_id/secret, username, password │ ▼ access_token (JWT) Backend ──► verifies locally: JWKS (/.well-known/jwks, by kid), iss == http://localhost:8080, aud == inventory-app-client ──► auto-provisions the app `users` row (role NULL on first login) ──► returns {access_token, user: {role, role_label, project_id, ...}} │ ▼ Frontend stores the token (localStorage) + profile, navigates by role: pm → /inventory (store panel) admin / procurement_manager → / (procurement office) ``` - **Two Casdoor URLs exist on purpose:** `PROCUREMENT_CASDOOR_ENDPOINT` (`http://casdoor:8000`) is the in-network service URL used for token exchange and JWKS fetching; `PROCUREMENT_CASDOOR_ISSUER` (`http://localhost:8080`) is the browser-facing origin that Casdoor embeds in the `iss` claim (its `origin` env in docker-compose). Both must be set — see the `api` service environment block in `docker-compose.yml`. - **Logout:** JWTs are stateless; logout clears the token + profile on the client. The next protected call then gets `401`. There is no server-side session to revoke (a token stays valid until `exp` unless a blacklist is introduced — out of scope). - The old browser-redirect (PKCE / authorization-code) flow was removed together with the public `casdoor-config.js` (which exposed the client secret). The `password` grant is what powers the in-page form. --- ## 3. Authentication — status codes | Case | Status | Code | Message | |---|---|---|---| | Valid token | 200 | — | data | | Missing Authorization header | 401 | `UNAUTHORIZED` | احراز هویت لازم است — توکن Casdoor یا حالت توسعه را ارسال کنید | | Corrupted / unreadable token | 401 | `UNAUTHORIZED` | توکن نامعتبر است | | Unknown kid / bad signature | 401 | `UNAUTHORIZED` | امضای توکن نامعتبر است | | Expired / wrong iss / wrong aud | 401 | `UNAUTHORIZED` | توکن نامعتبر یا منقضی شده است | | Valid token, user has no role yet | 403 | `FORBIDDEN` | نقش شما هنوز در سیستم تعیین نشده است | | Valid token, wrong role for the section | 403 | `FORBIDDEN` | دسترسی به این بخش برای نقش شما مجاز نیست | | Casdoor unreachable | 503 | `UPSTREAM_UNAVAILABLE` | سرویس احراز هویت (Casdoor) در دسترس نیست | | Wrong Casdoor client config | 503 | `UPSTREAM_UNAVAILABLE` | سرویس احراز هویت به درستی پیکربندی نشده است | | Wrong username/password | 401 | `UNAUTHORIZED` | نام کاربری یا رمز عبور نادرست است | --- ## 4. Authorization — how User / Organization / Role / Permission / Application relate **Decision (agreed): roles stay in the app's own database. Casdoor is used for identity only.** No new authorization model was introduced. - **Casdoor Application** (`inventory-app`, client `inventory-app-client`): 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 `built-in` org.) - **User — two records per person:** 1. Casdoor user (login credential: name + password). 2. App `users` row, linked by `users.casdoor_id = Casdoor user id` (the JWT `sub` claim). Auto-provisioned on first login. - **Role** lives in `users.role` (not in Casdoor): `pm` | `procurement_manager` | `admin` (NULL until assigned). Assigned from the app's Organization panel (`/inventory/org`) by an admin / procurement manager. - **Permission** is enforced per-endpoint in the backend routers, not in Casdoor permission objects: - `admin` (super user): everything; can create users and assign roles/projects (adds PMs); can use Developer Mode to act as either of the two main roles. - `procurement_manager` (مدیر تدارکات): runs the procurement office; can also add/manage users and project managers (same Organization panel rights as admin); read-only view of the inventory panel. - `pm` (مدیر پروژه): store only — inventory dashboard, stock changes, daily control, warehouses, arrivals. Scoped to the single project in `projects.pm_user_id`. - **Protected vs open endpoints (current split):** `/auth`, `/users`, `/inventory`, `/pm` require authentication (Casdoor JWT, or dev headers in Developer Mode). The procurement CRUD endpoints (quotes, suppliers, materials, …) are still open — documented known behavior, not part of this phase. ### Bootstrapping the first admin A user is auto-provisioned with `role = NULL` on first login, and assigning roles requires an admin — so bootstrap the very first admin directly in the DB: ```sql -- list provisioned users SELECT id, casdoor_id, first_name, last_name, role FROM users; -- promote one of them (casdoor_id = the Casdoor user id) UPDATE users SET role = 'admin' WHERE casdoor_id = ''; ``` After that, log in as the admin and use the Organization panel (`/inventory/org`) for everything else. --- ## 5. Failure scenarios — where the error happens, what the user sees, how to debug | # | Scenario | Where it fails | What the user sees | Backend log (api container) | How to debug | |---|---|---|---|---|---| | 1 | Casdoor unavailable | `POST /auth/login` (token exchange) or JWKS fetch on protected endpoints | Login page: «سرویس احراز هویت (Casdoor) در دسترس نیست»; API: 503 | `Casdoor token endpoint unreachable (…): …` or `JWKS fetch failed (…): …` | `docker compose ps` — casdoor must be `Up`; from the api container: `python -c "import urllib.request; print(urllib.request.urlopen('http://casdoor:8000/').status)"` | | 2 | Backend unavailable | nginx `/api/` proxy | App shows «اتصال به سرور برقرار نشد» (frontend toast) | none (api is down) | `docker compose ps api`; `docker logs procurement-api`; curl `http://localhost:8000/health` | | 3 | Invalid client (wrong client_id/secret) | Casdoor token endpoint | 503 «سرویس احراز هویت به درستی پیکربندی نشده است» | `Casdoor rejected password grant: error=invalid_client description=…` | Compare `PROCUREMENT_CASDOOR_CLIENT_ID/SECRET` in docker-compose with the application in the Casdoor console (Applications → inventory-app) | | 4 | Invalid redirect URI | Casdoor authorization endpoint (browser flows only) | Casdoor's own error page in the browser | — (backend not involved) | The app no longer uses redirects. If a browser OIDC flow is re-enabled, the app's Redirect URIs in the Casdoor console must contain the current app URL (`http://localhost/login` with Traefik) | | 5 | Invalid / corrupted JWT | Backend JWT verification | 401 «توکن نامعتبر است» / «امضای توکن نامعتبر است» | `JWT unreadable: …` or `JWT kid '…' not found in JWKS` | Decode the token at jwt.io; check `kid` against `http://localhost:8080/.well-known/jwks` | | 6 | Expired JWT | Backend JWT verification | 401 «توکن نامعتبر یا منقضی شده است» | `JWT verification failed: … Signature has expired` | Token `exp` claim; lifetime = Application → `expireInHours` (24h) | | 7 | Missing JWT | Protected endpoint dependency | 401 «احراز هویت لازم است…» | normal access log, 401 | Send `Authorization: Bearer `; in the app this means the session was cleared — log in again | | 8 | Wrong audience | Backend JWT verification | 401 «توکن نامعتبر یا منقضی شده است» | `JWT verification failed: … audience` | Token `aud` must contain `inventory-app-client` (= `PROCUREMENT_CASDOOR_AUDIENCE`) | | 9 | Wrong issuer | Backend JWT verification | 401 «توکن نامعتبر یا منقضی شده است» | `JWT verification failed: … issuer` | Token `iss` must equal `PROCUREMENT_CASDOOR_ISSUER` (`http://localhost:8080`); Casdoor's `origin` env must stay `http://localhost:8080` | | 10 | User without permission | Role checks in `auth.py` / routers | 403 «نقش شما هنوز در سیستم تعیین نشده است» or «دسترسی به این بخش برای نقش شما مجاز نیست» | access log with 403 | Organization panel → assign the user a role; PMs additionally need a project via `/users/{id}/project` | --- ## 6. Authentication test results (run 2026-08-29, Docker compose stack) | Test | Action | Result | Evidence | |---|---|---|---| | 1 | Login: `POST /api/v1/auth/login` (tester/123456) | **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` «توکن نامعتبر است» | | 5 | Expired token (app lifetime set to ~1s, then restored to 24h) | **PASS** | 401 «توکن نامعتبر یا منقضی شده است» | | 6 | Logout: token cleared client-side → protected API | **PASS** | 200 before, 401 after cleanup | --- ## 7. Final checklist (re-verified after the login-page redesign, 2026-08-29) | # | Item | Result | |---|---|---| | 1 | Casdoor container works | ✅ Up; console + REST API respond | | 2 | Casdoor PostgreSQL works | ✅ Up; queried directly (orgs/users/apps) | | 3 | Backend can reach Casdoor | ✅ api container does token exchange + JWKS fetch (`casdoor:8000`) | | 4 | Frontend can reach Casdoor | ✅ network path open — though with the in-page flow the frontend only talks to the backend, which talks to Casdoor | | 5 | Seeder works | ✅ ran twice; idempotent (existing objects reported, nothing breaks) | | 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) | | 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 | | 13 | Login works | ✅ form login in the browser (tester → `/inventory`) | | 14 | Token is received | ✅ JWT returned by `POST /auth/login` | | 15 | Backend validates JWT | ✅ signature/kid/iss/aud/exp — wrong values each tested → 401 | | 16 | JWKS works | ✅ public `/.well-known/jwks` (kid `cert-built-in`) | | 17 | Authenticated API returns 200 | ✅ `GET /users/me` with Bearer token | | 18 | Missing token returns 401 | ✅ | | 19 | Invalid token returns 401 | ✅ | | 20 | Logout works | ✅ button clears the session → back to `/login`; protected API → 401 | | 21 | Authorization/Roles are understood | ✅ documented in §4 (roles in app DB, per-role rules) | | 22 | No secrets accidentally exposed | ✅ `casdoor-config.js` (with clientSecret) removed from public files; secret only in backend env + seeder | | 23 | Documentation is complete | ✅ this document (addresses, credentials, flows, failure scenarios, management) | --- ## 8. Stack changes in this release - **One PostgreSQL instance.** The separate casdoor-postgres container is gone: the app DB (`procurement`) and Casdoor's DB (`casdoor`) now live on the same `postgres:18` service. The `casdoor` role + database are created by `sql/02-casdoor-db.sql` on a fresh volume; the migration was done via `pg_dump`/`pg_restore` (pre-migration backups are kept on the host). - **Traefik v3 is the public edge.** Ports 80/443 (web/websecure); host rules for app / api / auth / s3 / storage (env-overridable, localhost defaults). The frontend nginx now only serves the SPA and proxies `/api` internally. Production (real domains, Let's Encrypt, HTTP→HTTPS redirect) is a one command with `docker-compose.prod.yml` — see its header for the env vars. Note: Traefik must be **v3.6+** (≤3.3 fails against Docker Engine 29.x), and the docker socket is mounted read-write (Docker Desktop rejects `:ro`). - **RBAC hardening (Organization / Admin).** Only the `admin` can change roles (`PATCH /users/{id}`); the procurement manager can only assign projects to project managers. The user-creation form is removed — users come from Casdoor via `POST /users/sync` (identity mirroring, roles untouched). The admin has a dedicated panel at `/admin` (sync + grant/ revoke «مدیر تدارکات»). - **Developer Mode is now a 3-step admin flow** on the login page: admin credentials → role switcher (ادمین / مدیر تدارکات / مدیر پروژه) → role-based routing (admin → `/admin`, manager → office, PM → inventory). - **Partial goods receipt** in «کالاهای در راه»: submitting any item with a valid quantity is enough; the rest stays in transit. The ledger shows «رسیده X از Y» / «در راه Z» and a «رسید جزیی» badge on the quote. The arrival-confirmation 500 (a `db.begin()` on an already-begun session) was fixed, and the receipt response now returns the real derived state. - **Daily control calendar** now renders day-of-month (was rendering the Jalali year), navigates by real Jalali months, and uses pure Jalali dates («امروز ۸ شهریور ۱۴۰۵»), with a blue accent on today and a ring on the selected day. - **Uploads** (invoice/proforma) no longer send `Content-Type: application/json` on multipart requests — the 400/422 «دادهی ارسالشده نامعتبر است» is fixed. - **Project filter** in the admin inventory views is fixed: empty/undefined query params are dropped (previously `project_id=undefined` → 400 → empty dropdowns). ## 9. Managing Casdoor yourself - **Console:** http://localhost:8080 — log in as **admin / 123**. From there you can manage Organizations, Users, Applications, Providers, Certificates. - **Seeding** (org `inventory`, app `inventory-app`, the four users) — re-runnable at any time: ```powershell python backend/tools/seed_casdoor.py # from the host # or: docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py ``` - **Adding a user end-to-end:** add the user in the Casdoor console (org `inventory`, set a password, passwordType `plain` for dev) → the user logs in through the app once (auto-provisions the `users` row) → assign the role and, for PMs, the project from the app's Organization panel (`/inventory/org`). - **Debugging tokens:** use the discovery + JWKS endpoints above; the api container logs every rejected token with the reason (`JWT verification failed: …`), so `docker logs procurement-api` is the first place to look.