Files
Metamorph/tasks/testing-m2.md
Knacky 700b563297 feat(m2): auth, JWT, invitations, bootstrap, RTOps SPA pages
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>
2026-05-11 06:16:48 +02:00

8.2 KiB

type, project, milestone, date
type project milestone date
testing Metamorph M2 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

make env
make up
make migrate                  # 27 tables (M1)

Récupère le token install dans les logs :

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 :

make print-install-token-force

2. /setup — création du 1er admin via curl

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é :

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

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

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

# 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

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)

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

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

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

make down
# ou full reset (DESTRUCTEUR) :
make clean