Crypto + tokens
- app/core/security.py: Argon2id PasswordHasher (time_cost=2, memory_cost=
64 MiB, parallelism=2) + opaque-token SHA-256 helpers (raw token shown
once, only the hash lives in the DB).
- app/core/jwt_tokens.py: HS256, claims iss/sub/type/jti/iat/exp. Access
1h, refresh 30d.
Services
- services/auth.py: login, refresh with token rotation + reuse-detection
chain revoke, logout (idempotent), change_password (forces logout-all).
- services/invitations.py: create, preview, accept, revoke. Default 7d TTL.
- services/bootstrap.py: seeds the 3 system groups (admin/redteam/blueteam),
consumes the install token, attaches the first user to admin.
- core/install_token.py: mints, persists in settings, marks consumed,
regenerate hook for /diag/reset.
API
- POST /setup (consume install token, create 1st admin) + GET /setup
(status).
- POST /auth/{login,refresh,logout,change-password} + GET /auth/me.
- POST /invitations + GET /invitations + GET /invitations/preview/<token> +
POST /invitations/accept/<token> + POST /invitations/<id>/revoke.
- POST /diag/reset: test-only kill switch (truncate auth tables + mint
fresh install token). Allowed in dev too (with WARNING log) so the e2e
suite can run against a make-up stack; production locked out.
Middleware
- @require_auth populates g.current_user (snapshot dataclass, session
closed before request handler runs).
- @require_perm(*codes): atomic perm union check; admin group bypasses.
Perm catalogue lands in M3, scaffolding here.
- flask-limiter: 10/min/IP on /auth/login & /auth/refresh, 5/min on
/auth/change-password & /setup, 10–20/min on invitation endpoints.
Disabled in APP_ENV=test.
CLI
- flask --app app.cli metamorph print-install-token [--force]
- flask --app app.cli metamorph seed-mitre (M4 placeholder)
Refresh cookie metamorph_refresh: HttpOnly + Secure (localhost is a secure
context for modern browsers) + SameSite=Strict + Path=/api/v1/auth/.
Email validation: app.api._validation.Email permissive RFC-shape regex so
internal TLDs (.local/.corp/.test) are accepted — pydantic.EmailStr's
deliverability check is too strict for red-team labs.
Frontend
- lib/{api,auth}.ts: access token in module memory, refresh cookie,
automatic 401-retry via /auth/refresh, useAuth() hook.
- components/{Layout,RequireAuth}.tsx + ui/{TextField,Alert}.tsx.
- pages/{Login,Setup,Register,Profile}.
Testing
- tests/test_auth_flow.py: 15 integration tests (24 backend total).
- e2e/tests/m2-auth.spec.ts: 8 Playwright tests (20 e2e total).
- tasks/testing-m2.md.
DoD: make test-api → 24 passed, make e2e → 20 passed; spec-reviewer pass
applied (Secure unconditional, refresh limit 10/min/IP).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
213 lines
8.2 KiB
Markdown
213 lines
8.2 KiB
Markdown
---
|
|
type: testing
|
|
project: Metamorph
|
|
milestone: M2
|
|
date: "2026-05-10"
|
|
---
|
|
|
|
# Comment tester M2 (auth, bootstrap, invitations)
|
|
|
|
> Procédure de validation manuelle + automatisée pour M2 (auth JWT, /setup, invitations, RBAC). Toutes les commandes se lancent depuis la racine.
|
|
|
|
## 0. Prérequis
|
|
|
|
Voir `tasks/testing-m0.md §0`. M2 n'ajoute aucune dépendance host (le pytest tourne dans un container éphémère via `make test-api`).
|
|
|
|
## 1. Bootstrap stack vide → premier admin
|
|
|
|
```bash
|
|
make env
|
|
make up
|
|
make migrate # 27 tables (M1)
|
|
```
|
|
|
|
Récupère le token install dans les logs :
|
|
```bash
|
|
make logs-api | grep -E "INSTALL TOKEN" | tail -1
|
|
# ou : podman logs metamorph-api 2>&1 | grep "INSTALL TOKEN"
|
|
```
|
|
|
|
Si la stack a été utilisée et le token consommé, force-mint un nouveau :
|
|
```bash
|
|
make print-install-token-force
|
|
```
|
|
|
|
## 2. /setup — création du 1er admin via curl
|
|
|
|
```bash
|
|
TOKEN="<paste-token-here>"
|
|
curl -s -X POST http://localhost:8080/api/v1/setup \
|
|
-H "Content-Type: application/json" \
|
|
-d "{\"install_token\":\"$TOKEN\",\"email\":\"admin@metamorph.local\",\"password\":\"AdminPass1234!\"}" | jq
|
|
# Attendu : {"ok":true,"user_id":"..."}
|
|
```
|
|
|
|
Vérifie que `/setup` ne peut plus être consommé :
|
|
```bash
|
|
curl -s http://localhost:8080/api/v1/setup | jq
|
|
# Attendu : {"completed":true}
|
|
```
|
|
|
|
## 3. /setup via la SPA
|
|
|
|
Ouvrir http://127.0.0.1:8080/setup dans le navigateur. Le formulaire affiche :
|
|
- **Install token** (paste depuis les logs)
|
|
- **Admin email**
|
|
- **Display name (optional)**
|
|
- **Password** + **Confirm password** (≥ 8 chars)
|
|
|
|
Le bouton « Create admin » appelle `POST /api/v1/setup` puis redirige vers `/login`. Si la stack a déjà un admin, la page affiche « Already done · Go to login → ».
|
|
|
|
## 4. Login + /auth/me
|
|
|
|
```bash
|
|
LOGIN=$(curl -s -c /tmp/cookies.txt -X POST http://localhost:8080/api/v1/auth/login \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"email":"admin@metamorph.local","password":"AdminPass1234!"}')
|
|
echo "$LOGIN" | jq
|
|
ACCESS=$(echo "$LOGIN" | jq -r .access_token)
|
|
|
|
curl -s http://localhost:8080/api/v1/auth/me -H "Authorization: Bearer $ACCESS" | jq
|
|
# Attendu : {is_admin: true, groups: ["admin"], email: "...", ...}
|
|
```
|
|
|
|
Le cookie `metamorph_refresh` est dans `/tmp/cookies.txt` (HTTPOnly, scope `/api/v1/auth/`).
|
|
|
|
Côté SPA : `/login` → email + password → redirige vers `/`. Le header affiche l'email courant via le testid `me-email` (`<span data-testid="me-email">…</span>`).
|
|
|
|
## 5. Refresh rotation
|
|
|
|
```bash
|
|
curl -s -b /tmp/cookies.txt -c /tmp/cookies.txt -X POST http://localhost:8080/api/v1/auth/refresh | jq
|
|
# Attendu : nouveau access_token, le cookie refresh est rotaté
|
|
```
|
|
|
|
L'ancien refresh token devient `revoked_at IS NOT NULL` en base ; un 2e usage du même refresh révoque la chaîne entière (détection de réutilisation).
|
|
|
|
## 6. Invitation → register → 2e login
|
|
|
|
```bash
|
|
# Admin crée invitation
|
|
INV=$(curl -s -X POST http://localhost:8080/api/v1/invitations \
|
|
-H "Authorization: Bearer $ACCESS" -H "Content-Type: application/json" \
|
|
-d '{"email_hint":"alice@metamorph.local"}')
|
|
echo "$INV" | jq
|
|
INV_TOKEN=$(echo "$INV" | jq -r .token)
|
|
|
|
# Preview (anonyme)
|
|
curl -s "http://localhost:8080/api/v1/invitations/preview/$INV_TOKEN" | jq
|
|
|
|
# Acceptance
|
|
curl -s -X POST "http://localhost:8080/api/v1/invitations/accept/$INV_TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"email":"alice@metamorph.local","password":"AlicePass1234!"}' | jq
|
|
|
|
# Login Alice
|
|
curl -s -X POST http://localhost:8080/api/v1/auth/login \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"email":"alice@metamorph.local","password":"AlicePass1234!"}' | jq -r .access_token | head -c 40
|
|
```
|
|
|
|
Côté SPA : ouvrir `http://127.0.0.1:8080/register?token=<INV_TOKEN>`. La page affiche le hint email, demande password + confirm, redirige vers `/login` après acceptance.
|
|
|
|
## 7. RBAC — non-admin reçoit 403
|
|
|
|
```bash
|
|
ALICE_ACCESS=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"email":"alice@metamorph.local","password":"AlicePass1234!"}' | jq -r .access_token)
|
|
|
|
curl -s -o /dev/null -w "HTTP %{http_code}\n" -X POST http://localhost:8080/api/v1/invitations \
|
|
-H "Authorization: Bearer $ALICE_ACCESS" -H "Content-Type: application/json" -d '{}'
|
|
# Attendu : HTTP 403
|
|
```
|
|
|
|
## 8. Change password (force logout-all)
|
|
|
|
```bash
|
|
curl -s -X POST http://localhost:8080/api/v1/auth/change-password \
|
|
-H "Authorization: Bearer $ACCESS" -H "Content-Type: application/json" \
|
|
-d '{"current_password":"AdminPass1234!","new_password":"AdminPass5678!"}' | jq
|
|
# Attendu : {"ok":true}
|
|
```
|
|
|
|
Tous les refresh tokens du user sont révoqués → toute autre session existante repasse par /login.
|
|
|
|
## 9. Profile (SPA)
|
|
|
|
Une fois loggué, ouvrir `/profile` :
|
|
- Carte **Identity** (email, display name, locale)
|
|
- Carte **Groups** (tags)
|
|
- Carte **Permissions** (admin → tag « ADMIN — bypasses checks »)
|
|
- Carte **Change password** (current + new + confirm → submit → redirige vers /login après 1.5 s)
|
|
|
|
Logout via le bouton du header → redirige vers `/login`. Tenter `/profile` sans session → redirige vers `/login`.
|
|
|
|
## 10. Tests automatisés
|
|
|
|
### 10.1 — Backend pytest
|
|
|
|
```bash
|
|
make test-api
|
|
```
|
|
|
|
**Attendu** : **24 passed** (1 health + 8 schema + 15 auth flow).
|
|
|
|
Détail M2 (`tests/test_auth_flow.py`) :
|
|
| Test | Couvre |
|
|
|---|---|
|
|
| setup_status_starts_uncompleted | /setup état initial |
|
|
| setup_creates_first_admin | bootstrap consomme le token, crée admin |
|
|
| setup_status_now_completed | idempotence |
|
|
| setup_replay_is_blocked | 409 si déjà fait |
|
|
| login_and_me | flux login → /me complet |
|
|
| login_with_wrong_password_returns_401 | aucune énumération |
|
|
| me_without_token_returns_401 | bearer requis |
|
|
| refresh_rotates_and_old_token_is_revoked | rotation chaîne |
|
|
| refresh_with_no_cookie_returns_401 | cookie obligatoire |
|
|
| logout_clears_cookie_and_is_idempotent | idempotence |
|
|
| admin_creates_invitation_and_invitee_accepts | flux complet |
|
|
| unauthenticated_cannot_create_invitation | auth requis |
|
|
| non_admin_cannot_create_invitation | RBAC 403 |
|
|
| used_invitation_cannot_be_accepted_twice | usage unique |
|
|
| change_password_revokes_all_refresh_tokens | logout-all |
|
|
|
|
### 10.2 — Playwright e2e
|
|
|
|
```bash
|
|
make e2e
|
|
```
|
|
|
|
**Attendu** : **20 passed** (8 M0 + 4 M1 + 8 M2).
|
|
|
|
Détail M2 (`e2e/tests/m2-auth.spec.ts`) :
|
|
| Test | Couvre |
|
|
|---|---|
|
|
| setup status is uncompleted before bootstrap | API contract |
|
|
| SPA setup form creates the first admin | UI /setup |
|
|
| SPA login works and reveals the profile page | UI /login + /profile |
|
|
| admin issues an invitation via the API and the front renders the registration form | UI /register?token=… |
|
|
| invitee submits the registration form and can log in | UI register accept |
|
|
| non-admin gets 403 on the admin invitations endpoint | RBAC |
|
|
| refresh token rotation works through the SPA | rotation |
|
|
| logout clears the session and redirects to login | logout |
|
|
|
|
Le rapport HTML : `e2e/playwright-report/index.html`. JUnit : `e2e/playwright-report/junit.xml`.
|
|
|
|
## 11. Pièges connus (M2 spécifiques)
|
|
|
|
- **Cookie `Secure` en HTTP** : `secure=True` fait rejeter le cookie par le browser sur HTTP. Métamorph utilise `secure = APP_ENV in ("prod","staging")` — en `dev`/`test` le cookie est non-secure. En prod, le reverse proxy externe doit terminer la TLS.
|
|
- **`pydantic.EmailStr` rejette les TLD réservés** (`.local`, `.corp`, `.test`) avec `globally_deliverable=True`. Métamorph utilise un type `Email` permissif via regex (cf. `app/api/_validation.py`).
|
|
- **`getByLabel` Playwright** récupère le **nom accessible** de l'input. Si la `<label>` enveloppe l'input ET le hint, le hint pollue le nom → matchs ratés. Le composant `TextField` met le hint en sibling, pas à l'intérieur du `<label>`.
|
|
- **Rate-limit `/auth/login` (10/min)** : peut bloquer une suite de tests. `flask-limiter` est désactivé par construction quand `APP_ENV=test`.
|
|
- **`/diag/reset`** est exposé seulement quand `APP_ENV in ("dev","test")`. Truncate les tables auth + force-mint un nouveau token install. **Ne jamais activer en prod.**
|
|
- **Compose recreate** : `make rebuild` ne recrée pas les containers — il faut `make down && make up` après un changement front pour que nginx serve le nouveau bundle.
|
|
|
|
## 12. Teardown
|
|
|
|
```bash
|
|
make down
|
|
# ou full reset (DESTRUCTEUR) :
|
|
make clean
|
|
```
|