- backups/procurement.dump regenerated from the clean seeded state (was the junk test data); README documents both the app seeder and the restore path - docs/casdoor-auth.md: later-batch changelog (fulfillment split, demo seeder, API-driven topics, hub-and-spoke, count modal, FOUND ceiling, semantic colors)
289 lines
19 KiB
Markdown
289 lines
19 KiB
Markdown
# 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).
|
||
|
||
### Later batch (ledger fulfillment, demo data, hub-and-spoke)
|
||
|
||
- **Ledger fulfillment split** («رسید جزیی»): the quotation card shows a
|
||
counter badge («رسید: X از Y قلم» / «تکمیل شد» / «در راه»); each line item
|
||
visually splits into its received part (رسید شده ✓ + date + warehouse from
|
||
the receipt allocations) and the in-transit remainder — quantities always
|
||
sum to the original line (totals unchanged). Display-only split, agreed to
|
||
keep the receipt/decision chain intact.
|
||
- **Demo data seeder** (`backend/tools/seed.py`): wipes the app tables and
|
||
seeds a clean realistic dataset (3 projects × 2 warehouses, 4 suppliers,
|
||
12 materials across the 8 categories, 4 quotes in offered / in-transit /
|
||
partial / completed states with matching stock and receipts). Roles are
|
||
bound to the Casdoor users by username (run `seed_casdoor.py` first).
|
||
`backups/*.dump` are refreshed after seeding — teammates restore with
|
||
`backups/restore.ps1`.
|
||
- **Topics are API-driven**: the 8-topic grids (suppliers, wizard, units)
|
||
now come from `/material-categories` via the store instead of a hardcoded
|
||
list (which had a mismatched label «ابزار و تجهیزات ساخت»).
|
||
- **Hub-and-spoke navigation**: the tab strip lives only on «داشبورد انبار»;
|
||
sub-views (تغییر موجودی، کنترل روزانه، انبارها، کالاهای در راه، سازماندهی)
|
||
hide it and return via «بازگشت به داشبورد». «افزایش موجود» became the
|
||
«تغییر موجودی» entry button.
|
||
- **Stock count**: submission goes through a confirmation modal showing
|
||
current vs counted vs variance (کسری/مازاد) before the STOCK_COUNT
|
||
transaction is posted.
|
||
- **Found-items ceiling**: `found_qty ≤ missing balance` enforced client-side
|
||
(red input + inline warning, submit disabled) and server-side (422 with the
|
||
remaining balance); fully recovered losses drop off the pending list.
|
||
- **Semantic colors**: emerald = inflow (خرید/برگشت/پیدا شده), rose =
|
||
outflow (برداشت/ضایعات/اتلاف/مفقودی), blue = neutral (انتقال/شمارش
|
||
موجودی) across the warehouse module; quantities are shown as absolute
|
||
values with color-coded badges instead of ± signs.
|
||
|
||
## 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.
|