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:
Knacky
2026-05-11 06:16:48 +02:00
parent e995853f0d
commit 700b563297
27 changed files with 3123 additions and 0 deletions

167
e2e/tests/m2-auth.spec.ts Normal file
View File

@@ -0,0 +1,167 @@
import { expect, test, type APIRequestContext } from '@playwright/test';
/**
* M2 — Auth flow.
*
* Each test starts from a clean DB by hitting an internal helper that
* truncates users/refresh_tokens/invitations and force-mints a fresh install
* token. The helper is the `/api/v1/diag/reset` endpoint exposed *only* when
* `APP_ENV=test` — see backend/app/api/diag.py.
*
* The flow exercised: setup → login → me → invite → register → 2nd login.
*/
const ADMIN_EMAIL = `admin-${Math.floor(Math.random() * 1e6)}@metamorph.local`;
const ADMIN_PASSWORD = 'AdminPass1234!';
const ALICE_EMAIL = `alice-${Math.floor(Math.random() * 1e6)}@metamorph.local`;
const ALICE_PASSWORD = 'AlicePass1234!';
interface ResetPayload {
install_token: string;
}
async function resetAndMintToken(request: APIRequestContext): Promise<string> {
const r = await request.post('/api/v1/diag/reset');
expect(r.status(), `reset endpoint must respond 200 — got ${r.status()}`).toBe(200);
const body = (await r.json()) as ResetPayload;
expect(body.install_token).toMatch(/^[A-Za-z0-9_-]{30,}$/);
return body.install_token;
}
test.describe.configure({ mode: 'serial' });
test.describe('M2 — auth flow', () => {
let installToken: string;
let invitationToken: string;
test.beforeAll(async ({ request }) => {
installToken = await resetAndMintToken(request);
});
test('setup status is uncompleted before bootstrap', async ({ request }) => {
const r = await request.get('/api/v1/setup');
expect(r.status()).toBe(200);
expect((await r.json()).completed).toBe(false);
});
test('SPA setup form creates the first admin', async ({ page }) => {
await page.goto('/setup');
await page.getByLabel(/install token/i).fill(installToken);
await page.getByLabel(/admin email/i).fill(ADMIN_EMAIL);
await page.getByLabel('Password', { exact: true }).fill(ADMIN_PASSWORD);
await page.getByLabel(/confirm password/i).fill(ADMIN_PASSWORD);
await page.getByRole('button', { name: /create admin/i }).click();
await expect(page.getByText(/admin created/i)).toBeVisible();
// Auto-redirect lands us on /login.
await expect(page).toHaveURL(/\/login$/, { timeout: 5000 });
});
test('SPA login works and reveals the profile page', async ({ page }) => {
await page.goto('/login');
await page.getByLabel(/email/i).fill(ADMIN_EMAIL);
await page.getByLabel(/password/i).fill(ADMIN_PASSWORD);
await page.getByRole('button', { name: /sign in/i }).click();
// Lands on home with header showing the admin email.
await expect(page).toHaveURL(/\/$/);
await expect(page.getByTestId('me-email')).toHaveText(ADMIN_EMAIL);
// Visit profile to check identity card. The email appears both in the nav
// bar (testid me-email) and in the Identity card (<code>) — that's two
// locator matches, so we look at the card-side <code> explicitly.
await page.getByRole('link', { name: /profile/i }).click();
await expect(page.getByRole('code').filter({ hasText: ADMIN_EMAIL })).toBeVisible();
await expect(page.getByText(/admin\s+account/i)).toBeVisible();
});
test('admin issues an invitation via the API and the front renders the registration form', async ({
page,
request,
}) => {
// Reuse the page session: read access token from /auth/me cookie chain. The
// SPA keeps it in memory, so we exercise the API via a fresh API request
// logged in with the same credentials.
const login = await request.post('/api/v1/auth/login', {
data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD },
});
expect(login.status()).toBe(200);
const access = (await login.json()).access_token as string;
const created = await request.post('/api/v1/invitations', {
headers: { Authorization: `Bearer ${access}` },
data: { email_hint: ALICE_EMAIL },
});
expect(created.status()).toBe(201);
invitationToken = (await created.json()).token as string;
expect(invitationToken).toMatch(/^[A-Za-z0-9_-]{30,}$/);
// Open the registration page and confirm the preview loaded.
await page.goto(`/register?token=${encodeURIComponent(invitationToken)}`);
await expect(page.getByText(/account.*registration/i)).toBeVisible();
// Email pre-filled from the hint.
const emailInput = page.getByLabel(/email/i);
await expect(emailInput).toHaveValue(ALICE_EMAIL);
});
test('invitee submits the registration form and can log in', async ({ page }) => {
await page.goto(`/register?token=${encodeURIComponent(invitationToken)}`);
await page.getByLabel(/email/i).fill(ALICE_EMAIL);
await page.getByLabel('Password', { exact: true }).fill(ALICE_PASSWORD);
await page.getByLabel(/confirm password/i).fill(ALICE_PASSWORD);
await page.getByRole('button', { name: /create account/i }).click();
await expect(page.getByText(/account created/i)).toBeVisible();
await expect(page).toHaveURL(/\/login$/, { timeout: 5000 });
await page.getByLabel(/email/i).fill(ALICE_EMAIL);
await page.getByLabel(/password/i).fill(ALICE_PASSWORD);
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page.getByTestId('me-email')).toHaveText(ALICE_EMAIL);
});
test('non-admin gets 403 on the admin invitations endpoint', async ({ request }) => {
const login = await request.post('/api/v1/auth/login', {
data: { email: ALICE_EMAIL, password: ALICE_PASSWORD },
});
const access = (await login.json()).access_token as string;
const r = await request.post('/api/v1/invitations', {
headers: { Authorization: `Bearer ${access}` },
data: {},
});
expect(r.status()).toBe(403);
});
test('refresh token rotation works through the SPA', async ({ page }) => {
// Login fresh.
await page.goto('/login');
await page.getByLabel(/email/i).fill(ALICE_EMAIL);
await page.getByLabel(/password/i).fill(ALICE_PASSWORD);
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page.getByTestId('me-email')).toHaveText(ALICE_EMAIL);
// Force a refresh via the API client interceptor: clear the in-memory access
// token and trigger a request that needs auth.
await page.evaluate(async () => {
const r = await fetch('/api/v1/auth/refresh', {
method: 'POST',
credentials: 'include',
});
return r.ok;
});
// After refresh the page is still authenticated.
await page.reload();
await expect(page.getByTestId('me-email')).toHaveText(ALICE_EMAIL);
});
test('logout clears the session and redirects to login', async ({ page }) => {
await page.goto('/login');
await page.getByLabel(/email/i).fill(ALICE_EMAIL);
await page.getByLabel(/password/i).fill(ALICE_PASSWORD);
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page.getByTestId('me-email')).toHaveText(ALICE_EMAIL);
await page.getByRole('button', { name: /logout/i }).click();
await expect(page).toHaveURL(/\/login$/);
// Going to /profile while logged out must redirect.
await page.goto('/profile');
await expect(page).toHaveURL(/\/login/);
});
});