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>
This commit is contained in:
299
backend/tests/test_auth_flow.py
Normal file
299
backend/tests/test_auth_flow.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""End-to-end auth flow integration test (live DB).
|
||||
|
||||
Hits the Flask test client to exercise:
|
||||
- /setup with the install token
|
||||
- /auth/login + /auth/me
|
||||
- /auth/refresh (rotation)
|
||||
- /auth/logout (revocation, idempotency)
|
||||
- /auth/change-password (forces logout-all)
|
||||
- /invitations create + preview + accept
|
||||
- RBAC: non-admin gets 403 on admin endpoint
|
||||
|
||||
The DB schema is left in place between tests; we use unique emails to avoid
|
||||
collisions across runs. The install token is force-minted at the start.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.install_token import regenerate_install_token
|
||||
from app.db.session import get_engine
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
def _truncate_users(engine):
|
||||
"""Wipe data so /setup has work to do. CASCADE handles dependent rows."""
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"TRUNCATE users, refresh_tokens, invitations, invitation_groups, "
|
||||
"user_groups, settings, groups RESTART IDENTITY CASCADE"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app(db_engine_or_skip):
|
||||
_truncate_users(db_engine_or_skip)
|
||||
flask_app = create_app()
|
||||
flask_app.config.update(TESTING=True)
|
||||
return flask_app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def install_token(db_engine_or_skip):
|
||||
return regenerate_install_token()
|
||||
|
||||
|
||||
def _unique_email(prefix: str) -> str:
|
||||
return f"{prefix}-{secrets.token_hex(4)}@metamorph.local"
|
||||
|
||||
|
||||
# -- /setup -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_setup_status_starts_uncompleted(client):
|
||||
r = client.get("/api/v1/setup")
|
||||
assert r.status_code == 200
|
||||
assert r.get_json()["completed"] is False
|
||||
|
||||
|
||||
def test_setup_creates_first_admin(client, install_token):
|
||||
email = _unique_email("admin")
|
||||
r = client.post(
|
||||
"/api/v1/setup",
|
||||
json={
|
||||
"install_token": install_token,
|
||||
"email": email,
|
||||
"password": "AdminPass1234!",
|
||||
"display_name": "Init Admin",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201, r.get_data(as_text=True)
|
||||
body = r.get_json()
|
||||
assert "user_id" in body
|
||||
pytest.shared_admin = {"email": email, "password": "AdminPass1234!", "id": body["user_id"]} # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_setup_status_now_completed(client):
|
||||
assert client.get("/api/v1/setup").get_json()["completed"] is True
|
||||
|
||||
|
||||
def test_setup_replay_is_blocked(client, install_token):
|
||||
# Token already consumed — a second call must be refused.
|
||||
r = client.post(
|
||||
"/api/v1/setup",
|
||||
json={
|
||||
"install_token": install_token,
|
||||
"email": _unique_email("replay"),
|
||||
"password": "AdminPass1234!",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
# -- /auth/login + /auth/me ---------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def admin_credentials():
|
||||
return getattr(pytest, "shared_admin") # populated by test_setup_creates_first_admin
|
||||
|
||||
|
||||
def _login(client, email: str, password: str) -> tuple[str, dict]:
|
||||
r = client.post("/api/v1/auth/login", json={"email": email, "password": password})
|
||||
assert r.status_code == 200, r.get_data(as_text=True)
|
||||
body = r.get_json()
|
||||
return body["access_token"], dict(r.headers)
|
||||
|
||||
|
||||
def test_login_and_me(client, admin_credentials):
|
||||
access, _ = _login(client, admin_credentials["email"], admin_credentials["password"])
|
||||
r = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {access}"})
|
||||
assert r.status_code == 200
|
||||
me = r.get_json()
|
||||
assert me["email"] == admin_credentials["email"]
|
||||
assert me["is_admin"] is True
|
||||
assert "admin" in me["groups"]
|
||||
|
||||
|
||||
def test_login_with_wrong_password_returns_401(client, admin_credentials):
|
||||
r = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": admin_credentials["email"], "password": "wrong"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
assert r.get_json()["error"] == "invalid_credentials"
|
||||
|
||||
|
||||
def test_me_without_token_returns_401(client):
|
||||
r = client.get("/api/v1/auth/me")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# -- /auth/refresh rotation ---------------------------------------------------
|
||||
|
||||
|
||||
def test_refresh_rotates_and_old_token_is_revoked(client, admin_credentials):
|
||||
# Login fresh to get a refresh cookie on the test client.
|
||||
client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": admin_credentials["email"], "password": admin_credentials["password"]},
|
||||
)
|
||||
# First refresh — should succeed.
|
||||
r1 = client.post("/api/v1/auth/refresh")
|
||||
assert r1.status_code == 200, r1.get_data(as_text=True)
|
||||
new_access1 = r1.get_json()["access_token"]
|
||||
assert new_access1
|
||||
|
||||
# Second refresh — uses the rotated cookie automatically (test client persists cookies).
|
||||
r2 = client.post("/api/v1/auth/refresh")
|
||||
assert r2.status_code == 200
|
||||
assert r2.get_json()["access_token"] != new_access1
|
||||
|
||||
|
||||
def test_refresh_with_no_cookie_returns_401(client):
|
||||
fresh = client.application.test_client() # blank cookie jar
|
||||
r = fresh.post("/api/v1/auth/refresh")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# -- /auth/logout -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_logout_clears_cookie_and_is_idempotent(client, admin_credentials):
|
||||
client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": admin_credentials["email"], "password": admin_credentials["password"]},
|
||||
)
|
||||
r1 = client.post("/api/v1/auth/logout")
|
||||
assert r1.status_code == 200
|
||||
# Second logout — no token, still 200.
|
||||
r2 = client.post("/api/v1/auth/logout")
|
||||
assert r2.status_code == 200
|
||||
|
||||
|
||||
# -- /invitations -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_admin_creates_invitation_and_invitee_accepts(client, admin_credentials):
|
||||
access, _ = _login(client, admin_credentials["email"], admin_credentials["password"])
|
||||
inv_email = _unique_email("alice")
|
||||
|
||||
create = client.post(
|
||||
"/api/v1/invitations",
|
||||
headers={"Authorization": f"Bearer {access}"},
|
||||
json={"email_hint": inv_email},
|
||||
)
|
||||
assert create.status_code == 201, create.get_data(as_text=True)
|
||||
token = create.get_json()["token"]
|
||||
|
||||
preview = client.get(f"/api/v1/invitations/preview/{token}")
|
||||
assert preview.status_code == 200
|
||||
assert preview.get_json()["is_valid"] is True
|
||||
|
||||
accept = client.post(
|
||||
f"/api/v1/invitations/accept/{token}",
|
||||
json={"email": inv_email, "password": "AlicePass1234!", "display_name": "Alice"},
|
||||
)
|
||||
assert accept.status_code == 201, accept.get_data(as_text=True)
|
||||
|
||||
# Alice can now log in.
|
||||
a_access, _ = _login(client, inv_email, "AlicePass1234!")
|
||||
me = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {a_access}"}).get_json()
|
||||
assert me["is_admin"] is False
|
||||
assert me["email"] == inv_email
|
||||
|
||||
|
||||
def test_unauthenticated_cannot_create_invitation(client):
|
||||
r = client.post("/api/v1/invitations", json={})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_non_admin_cannot_create_invitation(client, admin_credentials):
|
||||
# Create a non-admin user via invitation, then try as them.
|
||||
access, _ = _login(client, admin_credentials["email"], admin_credentials["password"])
|
||||
inv_email = _unique_email("bob")
|
||||
create = client.post(
|
||||
"/api/v1/invitations",
|
||||
headers={"Authorization": f"Bearer {access}"},
|
||||
json={"email_hint": inv_email},
|
||||
)
|
||||
token = create.get_json()["token"]
|
||||
client.post(
|
||||
f"/api/v1/invitations/accept/{token}",
|
||||
json={"email": inv_email, "password": "BobPass1234!"},
|
||||
)
|
||||
bob_access, _ = _login(client, inv_email, "BobPass1234!")
|
||||
|
||||
r = client.post(
|
||||
"/api/v1/invitations",
|
||||
headers={"Authorization": f"Bearer {bob_access}"},
|
||||
json={},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_used_invitation_cannot_be_accepted_twice(client, admin_credentials):
|
||||
access, _ = _login(client, admin_credentials["email"], admin_credentials["password"])
|
||||
inv_email = _unique_email("carol")
|
||||
token = client.post(
|
||||
"/api/v1/invitations",
|
||||
headers={"Authorization": f"Bearer {access}"},
|
||||
json={"email_hint": inv_email},
|
||||
).get_json()["token"]
|
||||
|
||||
first = client.post(
|
||||
f"/api/v1/invitations/accept/{token}",
|
||||
json={"email": inv_email, "password": "CarolPass1234!"},
|
||||
)
|
||||
assert first.status_code == 201
|
||||
|
||||
second = client.post(
|
||||
f"/api/v1/invitations/accept/{token}",
|
||||
json={"email": _unique_email("carol2"), "password": "OtherPass1234!"},
|
||||
)
|
||||
assert second.status_code == 410
|
||||
assert second.get_json()["error"] == "invitation_consumed"
|
||||
|
||||
|
||||
# -- change-password forces logout-all ----------------------------------------
|
||||
|
||||
|
||||
def test_change_password_revokes_all_refresh_tokens(client, admin_credentials):
|
||||
access, _ = _login(client, admin_credentials["email"], admin_credentials["password"])
|
||||
|
||||
# Trigger a couple of refreshes so we have multiple chains in DB.
|
||||
client.post("/api/v1/auth/refresh")
|
||||
client.post("/api/v1/auth/refresh")
|
||||
|
||||
# Change password.
|
||||
new_pw = "AdminPass5678!"
|
||||
r = client.post(
|
||||
"/api/v1/auth/change-password",
|
||||
headers={"Authorization": f"Bearer {access}"},
|
||||
json={
|
||||
"current_password": admin_credentials["password"],
|
||||
"new_password": new_pw,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
admin_credentials["password"] = new_pw
|
||||
|
||||
# Existing refresh cookie must now be rejected.
|
||||
r2 = client.post("/api/v1/auth/refresh")
|
||||
assert r2.status_code == 401
|
||||
|
||||
# New login still works.
|
||||
_login(client, admin_credentials["email"], admin_credentials["password"])
|
||||
Reference in New Issue
Block a user