Files
ERP/docs/casdoor-auth.md
T
ali_mohaghegh 31290ffbda docs: README + casdoor-auth guide for the v1.3 changes
- README: Traefik edge (app at http://localhost), single postgres (casdoor
  DB inside), prod overlay usage, partial receipts, admin panel, dev-mode
  3-step flow, layout for infra/ and 02-casdoor-db.sql
- docs/casdoor-auth.md: new section «Stack changes in this release» (DB
  merge, Traefik, RBAC, partial receipt, calendar, upload, project filter),
  updated addresses and redirect-URI notes
2026-09-01 17:28:45 +03:30

256 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 = '<casdoor-user-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 <token>`; 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.