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:
30
backend/app/api/_validation.py
Normal file
30
backend/app/api/_validation.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Lightweight email validator that tolerates internal/lab TLDs (.local, .corp, …).
|
||||
|
||||
`pydantic.EmailStr` relies on `email-validator` with `globally_deliverable=True`,
|
||||
which rejects RFC 6761 special-use domains. Red-team and corporate intranet
|
||||
deployments routinely use such suffixes — we accept any RFC-shape email and
|
||||
defer deliverability checks to the operator.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import AfterValidator
|
||||
|
||||
# Permissive RFC-shape pattern: local-part 1..64 chars, domain has at least one
|
||||
# dot, each label is 1..63 chars of letters/digits/hyphens, total ≤ 254.
|
||||
_EMAIL_RE = re.compile(
|
||||
r"^(?=.{1,254}$)[A-Za-z0-9._%+\-]{1,64}@[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?)+$"
|
||||
)
|
||||
|
||||
|
||||
def _validate_email(value: str) -> str:
|
||||
v = value.strip()
|
||||
if not _EMAIL_RE.match(v):
|
||||
raise ValueError("not a valid email address")
|
||||
return v.lower()
|
||||
|
||||
|
||||
Email = Annotated[str, AfterValidator(_validate_email)]
|
||||
157
backend/app/api/auth.py
Normal file
157
backend/app/api/auth.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""Authentication endpoints.
|
||||
|
||||
`POST /auth/login` returns the access token in the body and sets the refresh
|
||||
token in an HTTPOnly cookie scoped to `/api/v1/auth/`. The cookie is
|
||||
`Secure; SameSite=Strict` and only the matching paths can read it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, g, jsonify, make_response, request
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from app.api._validation import Email
|
||||
from app.core.auth_decorators import require_auth
|
||||
from app.core.config import settings
|
||||
from app.core.rate_limit import limiter
|
||||
from app.services import auth as auth_svc
|
||||
|
||||
bp = Blueprint("auth", __name__, url_prefix="/auth")
|
||||
log = logging.getLogger("metamorph.api.auth")
|
||||
|
||||
REFRESH_COOKIE_NAME = "metamorph_refresh"
|
||||
REFRESH_COOKIE_PATH = "/api/v1/auth/"
|
||||
|
||||
|
||||
class LoginPayload(BaseModel):
|
||||
email: Email
|
||||
password: str = Field(min_length=1)
|
||||
|
||||
|
||||
class ChangePasswordPayload(BaseModel):
|
||||
current_password: str = Field(min_length=1)
|
||||
new_password: str = Field(min_length=8)
|
||||
|
||||
|
||||
def _set_refresh_cookie(resp, token: str, expires_at) -> None:
|
||||
resp.set_cookie(
|
||||
REFRESH_COOKIE_NAME,
|
||||
token,
|
||||
expires=expires_at,
|
||||
httponly=True,
|
||||
secure=True, # spec §M2; localhost is a secure context for modern browsers
|
||||
samesite="Strict",
|
||||
path=REFRESH_COOKIE_PATH,
|
||||
)
|
||||
|
||||
|
||||
def _clear_refresh_cookie(resp) -> None:
|
||||
resp.set_cookie(
|
||||
REFRESH_COOKIE_NAME,
|
||||
"",
|
||||
expires=0,
|
||||
httponly=True,
|
||||
secure=True, # spec §M2; localhost is a secure context for modern browsers
|
||||
samesite="Strict",
|
||||
path=REFRESH_COOKIE_PATH,
|
||||
)
|
||||
|
||||
|
||||
def _read_refresh_cookie() -> str | None:
|
||||
return request.cookies.get(REFRESH_COOKIE_NAME)
|
||||
|
||||
|
||||
def _serialize_pair(pair: auth_svc.TokenPair) -> dict:
|
||||
return {
|
||||
"access_token": pair.access_token,
|
||||
"token_type": "Bearer",
|
||||
"user_id": str(pair.user_id),
|
||||
}
|
||||
|
||||
|
||||
@bp.post("/login")
|
||||
@limiter.limit("10 per minute")
|
||||
def login():
|
||||
try:
|
||||
payload = LoginPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
try:
|
||||
pair = auth_svc.login(payload.email, payload.password)
|
||||
except auth_svc.InvalidCredentials:
|
||||
return jsonify({"error": "invalid_credentials"}), 401
|
||||
|
||||
resp = make_response(jsonify(_serialize_pair(pair)))
|
||||
_set_refresh_cookie(resp, pair.refresh_token, pair.refresh_expires_at)
|
||||
return resp
|
||||
|
||||
|
||||
@bp.post("/refresh")
|
||||
@limiter.limit("10 per minute")
|
||||
def refresh_endpoint():
|
||||
raw = _read_refresh_cookie()
|
||||
if not raw:
|
||||
return jsonify({"error": "no_refresh_cookie"}), 401
|
||||
try:
|
||||
pair = auth_svc.refresh(raw)
|
||||
except auth_svc.TokenRevoked:
|
||||
resp = make_response(jsonify({"error": "token_revoked"}), 401)
|
||||
_clear_refresh_cookie(resp)
|
||||
return resp
|
||||
except auth_svc.InvalidCredentials:
|
||||
resp = make_response(jsonify({"error": "invalid_refresh"}), 401)
|
||||
_clear_refresh_cookie(resp)
|
||||
return resp
|
||||
|
||||
resp = make_response(jsonify(_serialize_pair(pair)))
|
||||
_set_refresh_cookie(resp, pair.refresh_token, pair.refresh_expires_at)
|
||||
return resp
|
||||
|
||||
|
||||
@bp.post("/logout")
|
||||
def logout():
|
||||
raw = _read_refresh_cookie()
|
||||
if raw:
|
||||
auth_svc.logout(raw)
|
||||
resp = make_response(jsonify({"ok": True}))
|
||||
_clear_refresh_cookie(resp)
|
||||
return resp
|
||||
|
||||
|
||||
@bp.get("/me")
|
||||
@require_auth
|
||||
def me():
|
||||
u = g.current_user
|
||||
return jsonify(
|
||||
{
|
||||
"id": str(u.id),
|
||||
"email": u.email,
|
||||
"display_name": u.display_name,
|
||||
"locale": u.locale,
|
||||
"is_admin": u.is_admin,
|
||||
"groups": sorted(u.group_names),
|
||||
"permissions": sorted(u.permissions),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/change-password")
|
||||
@require_auth
|
||||
@limiter.limit("5 per minute")
|
||||
def change_password():
|
||||
try:
|
||||
payload = ChangePasswordPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
try:
|
||||
auth_svc.change_password(g.current_user.id, payload.current_password, payload.new_password)
|
||||
except auth_svc.InvalidCredentials:
|
||||
return jsonify({"error": "current_password_incorrect"}), 400
|
||||
except ValueError as e:
|
||||
return jsonify({"error": "weak_password", "message": str(e)}), 400
|
||||
|
||||
resp = make_response(jsonify({"ok": True}))
|
||||
_clear_refresh_cookie(resp)
|
||||
return resp
|
||||
146
backend/app/api/invitations.py
Normal file
146
backend/app/api/invitations.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""Invitation endpoints — admin issues, invitee previews + accepts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from flask import Blueprint, g, jsonify, make_response, request
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from app.api._validation import Email
|
||||
from app.core.auth_decorators import require_auth, require_perm
|
||||
from app.core.rate_limit import limiter
|
||||
from app.services import invitations as inv_svc
|
||||
|
||||
bp = Blueprint("invitations", __name__, url_prefix="/invitations")
|
||||
log = logging.getLogger("metamorph.api.invitations")
|
||||
|
||||
|
||||
class CreateInvitationPayload(BaseModel):
|
||||
email_hint: Email | None = None
|
||||
group_ids: list[uuid.UUID] = Field(default_factory=list)
|
||||
ttl_days: int | None = Field(default=None, ge=1, le=30)
|
||||
|
||||
|
||||
class AcceptInvitationPayload(BaseModel):
|
||||
email: Email
|
||||
password: str = Field(min_length=8)
|
||||
display_name: str | None = None
|
||||
|
||||
|
||||
@bp.post("")
|
||||
@require_auth
|
||||
@require_perm("invitation.create")
|
||||
def create():
|
||||
try:
|
||||
payload = CreateInvitationPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
ttl = (
|
||||
timedelta(days=payload.ttl_days)
|
||||
if payload.ttl_days is not None
|
||||
else inv_svc.INVITATION_TTL
|
||||
)
|
||||
result = inv_svc.create_invitation(
|
||||
created_by_user_id=g.current_user.id,
|
||||
email_hint=payload.email_hint,
|
||||
group_ids=payload.group_ids,
|
||||
ttl=ttl,
|
||||
)
|
||||
log.info(
|
||||
"metamorph.invitation.created",
|
||||
extra={
|
||||
"invitation_id": str(result.invitation_id),
|
||||
"by_user_id": str(g.current_user.id),
|
||||
"expires_at": result.expires_at.isoformat(),
|
||||
},
|
||||
)
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"id": str(result.invitation_id),
|
||||
"token": result.raw_token, # shown ONCE
|
||||
"expires_at": result.expires_at.isoformat(),
|
||||
}
|
||||
),
|
||||
201,
|
||||
)
|
||||
|
||||
|
||||
@bp.get("")
|
||||
@require_auth
|
||||
@require_perm("invitation.read")
|
||||
def list_active():
|
||||
rows = inv_svc.list_active()
|
||||
return jsonify(
|
||||
[
|
||||
{
|
||||
"id": str(r.id),
|
||||
"email_hint": r.email_hint,
|
||||
"expires_at": r.expires_at.isoformat(),
|
||||
"groups": [g.name for g in r.pre_assigned_groups],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/<invitation_id>/revoke")
|
||||
@require_auth
|
||||
@require_perm("invitation.revoke")
|
||||
def revoke(invitation_id: str):
|
||||
try:
|
||||
iid = uuid.UUID(invitation_id)
|
||||
except ValueError:
|
||||
return jsonify({"error": "invalid_id"}), 400
|
||||
ok = inv_svc.revoke(iid)
|
||||
if not ok:
|
||||
return jsonify({"error": "not_revocable"}), 404
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@bp.get("/preview/<token>")
|
||||
@limiter.limit("20 per minute")
|
||||
def preview(token: str):
|
||||
p = inv_svc.preview(token)
|
||||
return jsonify(
|
||||
{
|
||||
"is_valid": p.is_valid,
|
||||
"reason": p.reason,
|
||||
"email_hint": p.email_hint,
|
||||
"expires_at": p.expires_at.isoformat(),
|
||||
"groups": p.groups,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/accept/<token>")
|
||||
@limiter.limit("10 per minute")
|
||||
def accept(token: str):
|
||||
try:
|
||||
payload = AcceptInvitationPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
try:
|
||||
user_id = inv_svc.accept(
|
||||
token,
|
||||
email=payload.email,
|
||||
password=payload.password,
|
||||
display_name=payload.display_name,
|
||||
)
|
||||
except inv_svc.InvitationExpired:
|
||||
return jsonify({"error": "invitation_expired"}), 410
|
||||
except inv_svc.InvitationConsumed:
|
||||
return jsonify({"error": "invitation_consumed"}), 410
|
||||
except inv_svc.InvitationRevoked:
|
||||
return jsonify({"error": "invitation_revoked"}), 410
|
||||
except inv_svc.InvitationError as e:
|
||||
return jsonify({"error": "invitation_invalid", "message": str(e)}), 400
|
||||
except ValueError as e:
|
||||
return jsonify({"error": "invalid_request", "message": str(e)}), 400
|
||||
|
||||
return make_response(jsonify({"ok": True, "user_id": str(user_id)}), 201)
|
||||
79
backend/app/api/setup.py
Normal file
79
backend/app/api/setup.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Bootstrap endpoint — consumes the install token to create the first admin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, jsonify, make_response, request
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from app.api._validation import Email
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.rate_limit import limiter
|
||||
from app.db.session import session_scope
|
||||
from app.models.auth import User
|
||||
from app.services.bootstrap import (
|
||||
BootstrapError,
|
||||
bootstrap_admin,
|
||||
ensure_system_groups,
|
||||
)
|
||||
|
||||
bp = Blueprint("setup", __name__, url_prefix="/setup")
|
||||
log = logging.getLogger("metamorph.api.setup")
|
||||
|
||||
|
||||
class SetupPayload(BaseModel):
|
||||
install_token: str = Field(min_length=20)
|
||||
email: Email
|
||||
password: str = Field(min_length=8)
|
||||
display_name: str | None = None
|
||||
|
||||
|
||||
@bp.get("")
|
||||
def setup_status():
|
||||
"""Tell the SPA whether the bootstrap has already been done.
|
||||
|
||||
Used by the front to redirect to /setup vs /login on first paint.
|
||||
"""
|
||||
with session_scope() as s:
|
||||
any_user = s.scalar(select(User.id).limit(1)) is not None
|
||||
return jsonify({"completed": any_user})
|
||||
|
||||
|
||||
@bp.post("")
|
||||
@limiter.limit("5 per minute")
|
||||
def setup():
|
||||
try:
|
||||
payload = SetupPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
|
||||
try:
|
||||
result = bootstrap_admin(
|
||||
install_token=payload.install_token,
|
||||
email=payload.email,
|
||||
password=payload.password,
|
||||
display_name=payload.display_name,
|
||||
)
|
||||
except BootstrapError as e:
|
||||
return jsonify({"error": "bootstrap_failed", "message": str(e)}), 409
|
||||
except ValueError as e:
|
||||
return jsonify({"error": "invalid_request", "message": str(e)}), 400
|
||||
|
||||
log.warning(
|
||||
"metamorph.bootstrap.completed",
|
||||
extra={"user_id": str(result.user_id), "admin_group_id": str(result.admin_group_id)},
|
||||
)
|
||||
# Make sure the redteam/blueteam groups exist too (idempotent).
|
||||
ensure_system_groups()
|
||||
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"user_id": str(result.user_id),
|
||||
}
|
||||
),
|
||||
201,
|
||||
)
|
||||
65
backend/app/cli.py
Normal file
65
backend/app/cli.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Flask CLI entry point.
|
||||
|
||||
Used as `flask --app app.cli metamorph <subcommand>` (or via the make targets).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import click
|
||||
from flask import Flask
|
||||
from flask.cli import AppGroup
|
||||
|
||||
from app.core.install_token import (
|
||||
ensure_install_token,
|
||||
log_install_token_banner,
|
||||
regenerate_install_token,
|
||||
)
|
||||
from app.core.logging import configure_logging
|
||||
from app.services.bootstrap import ensure_system_groups
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def _create_cli_app() -> Flask:
|
||||
configure_logging(settings.LOG_LEVEL)
|
||||
return Flask("metamorph-cli")
|
||||
|
||||
|
||||
app = _create_cli_app()
|
||||
metamorph = AppGroup("metamorph", help="Metamorph admin commands.")
|
||||
|
||||
|
||||
@metamorph.command("print-install-token")
|
||||
@click.option(
|
||||
"--force",
|
||||
is_flag=True,
|
||||
help="Always mint a fresh token even if one is already pending.",
|
||||
)
|
||||
def print_install_token(force: bool):
|
||||
"""Mint and print the bootstrap install token (idempotent unless --force)."""
|
||||
ensure_system_groups()
|
||||
if force:
|
||||
token = regenerate_install_token()
|
||||
else:
|
||||
token = ensure_install_token()
|
||||
|
||||
if token is None:
|
||||
click.echo(
|
||||
"No install token minted: either at least one user already exists, "
|
||||
"or a token is already pending (use --force to mint a fresh one).",
|
||||
err=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
log_install_token_banner(token)
|
||||
|
||||
|
||||
@metamorph.command("seed-mitre")
|
||||
def seed_mitre():
|
||||
"""Placeholder for M4 — left so `make seed-mitre` doesn't crash."""
|
||||
click.echo("MITRE seeding will land in M4. (no-op for now)", err=True)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
app.cli.add_command(metamorph)
|
||||
139
backend/app/core/auth_decorators.py
Normal file
139
backend/app/core/auth_decorators.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Flask decorators for authentication + authorization.
|
||||
|
||||
Usage:
|
||||
@bp.get("/whatever")
|
||||
@require_auth # populates g.current_user
|
||||
def whatever():
|
||||
return jsonify(...)
|
||||
|
||||
@bp.post("/admin/users")
|
||||
@require_auth
|
||||
@require_perm("user.create") # checks the user's effective perms
|
||||
def create_user():
|
||||
...
|
||||
|
||||
`g.current_user` is a small `AuthenticatedUser` snapshot — no live ORM session.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from functools import wraps
|
||||
from typing import Callable
|
||||
|
||||
import jwt
|
||||
from flask import abort, g, request
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.jwt_tokens import decode_token
|
||||
from app.db.session import session_scope
|
||||
from app.models.auth import Permission, User
|
||||
from app.services.bootstrap import ADMIN_GROUP_NAME
|
||||
|
||||
log = logging.getLogger("metamorph.auth")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthenticatedUser:
|
||||
id: uuid.UUID
|
||||
email: str
|
||||
locale: str
|
||||
display_name: str | None
|
||||
is_admin: bool
|
||||
permissions: frozenset[str] = field(default_factory=frozenset)
|
||||
group_names: frozenset[str] = field(default_factory=frozenset)
|
||||
|
||||
|
||||
def _load_authenticated_user(user_id: uuid.UUID) -> AuthenticatedUser | None:
|
||||
with session_scope() as s:
|
||||
user = s.get(User, user_id)
|
||||
if user is None or user.deleted_at is not None or not user.is_active:
|
||||
return None
|
||||
group_names: set[str] = set()
|
||||
permissions: set[str] = set()
|
||||
for grp in user.groups:
|
||||
if grp.deleted_at is not None:
|
||||
continue
|
||||
group_names.add(grp.name)
|
||||
for perm in grp.permissions:
|
||||
permissions.add(perm.code)
|
||||
return AuthenticatedUser(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
locale=user.locale,
|
||||
display_name=user.display_name,
|
||||
is_admin=ADMIN_GROUP_NAME in group_names,
|
||||
permissions=frozenset(permissions),
|
||||
group_names=frozenset(group_names),
|
||||
)
|
||||
|
||||
|
||||
def _extract_bearer() -> str | None:
|
||||
raw = request.headers.get("Authorization", "")
|
||||
if not raw.lower().startswith("bearer "):
|
||||
return None
|
||||
return raw[7:].strip() or None
|
||||
|
||||
|
||||
def require_auth(fn: Callable):
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
token = _extract_bearer()
|
||||
if token is None:
|
||||
abort(401, description="missing bearer token")
|
||||
try:
|
||||
claims = decode_token(token, expected_type="access")
|
||||
except jwt.ExpiredSignatureError:
|
||||
abort(401, description="access token expired")
|
||||
except jwt.PyJWTError:
|
||||
abort(401, description="invalid access token")
|
||||
try:
|
||||
user_id = uuid.UUID(claims.sub)
|
||||
except ValueError:
|
||||
abort(401, description="malformed subject")
|
||||
snapshot = _load_authenticated_user(user_id)
|
||||
if snapshot is None:
|
||||
abort(401, description="user no longer active")
|
||||
g.current_user = snapshot
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def require_perm(*codes: str):
|
||||
"""Require any one of the listed permission codes.
|
||||
|
||||
Members of the system `admin` group bypass the check.
|
||||
"""
|
||||
|
||||
def decorator(fn: Callable):
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
user: AuthenticatedUser | None = getattr(g, "current_user", None)
|
||||
if user is None:
|
||||
abort(401, description="not authenticated")
|
||||
if user.is_admin:
|
||||
return fn(*args, **kwargs)
|
||||
if not any(code in user.permissions for code in codes):
|
||||
log.info(
|
||||
"metamorph.auth.permission_denied",
|
||||
extra={
|
||||
"user_id": str(user.id),
|
||||
"required": list(codes),
|
||||
"had": sorted(user.permissions),
|
||||
},
|
||||
)
|
||||
abort(403, description="insufficient permissions")
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def fetch_all_permissions() -> list[str]:
|
||||
"""Utility for debugging / admin UI: list every known permission code."""
|
||||
with session_scope() as s:
|
||||
return list(s.scalars(select(Permission.code).order_by(Permission.code)).all())
|
||||
147
backend/app/core/install_token.py
Normal file
147
backend/app/core/install_token.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""First-admin install token.
|
||||
|
||||
When the `users` table is empty at boot, we mint a one-shot opaque token,
|
||||
store its SHA-256 in `settings(key='install_token_hash')`, and log the raw
|
||||
token to stdout. The operator copies it from the logs and posts it to
|
||||
`/api/v1/setup` with the desired admin credentials.
|
||||
|
||||
Idempotency: as long as the token row exists and no admin has consumed it,
|
||||
subsequent boots reuse the same hash and re-emit the same token only if
|
||||
explicitly invoked via `flask metamorph print-install-token`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.security import generate_opaque_token, hash_opaque_token
|
||||
from app.db.session import session_scope
|
||||
from app.models.auth import User
|
||||
from app.models.setting import Setting
|
||||
|
||||
INSTALL_TOKEN_KEY = "install_token"
|
||||
log = logging.getLogger("metamorph.bootstrap")
|
||||
|
||||
# Setting JSONB shape: {"hash": "<sha256>", "issued_at": ISO, "expires_at": ISO|null, "consumed_at": ISO|null}
|
||||
|
||||
|
||||
def _users_exist() -> bool:
|
||||
with session_scope() as s:
|
||||
return s.execute(select(User.id).limit(1)).first() is not None
|
||||
|
||||
|
||||
def _read_setting() -> Setting | None:
|
||||
with session_scope() as s:
|
||||
return s.get(Setting, INSTALL_TOKEN_KEY)
|
||||
|
||||
|
||||
def _write_setting(payload: dict) -> None:
|
||||
with session_scope() as s:
|
||||
existing = s.get(Setting, INSTALL_TOKEN_KEY)
|
||||
if existing is None:
|
||||
s.add(
|
||||
Setting(
|
||||
key=INSTALL_TOKEN_KEY,
|
||||
value=payload,
|
||||
description="One-shot bootstrap token for the first admin (M2).",
|
||||
)
|
||||
)
|
||||
else:
|
||||
existing.value = payload
|
||||
|
||||
|
||||
def ensure_install_token(*, force: bool = False) -> str | None:
|
||||
"""Mint a token if no users exist and no live token is on file.
|
||||
|
||||
Returns the raw token if newly minted (caller is responsible for logging it),
|
||||
or None if the bootstrap is already consumed / not applicable.
|
||||
"""
|
||||
if _users_exist() and not force:
|
||||
return None
|
||||
|
||||
setting = _read_setting()
|
||||
if setting is not None and not force:
|
||||
value = setting.value or {}
|
||||
if value.get("consumed_at"):
|
||||
return None # consumed, do not mint again
|
||||
# A pending token exists; we don't know its raw value any more.
|
||||
# Caller must `force=True` to mint a new one (CLI command will do that).
|
||||
return None
|
||||
|
||||
token = generate_opaque_token()
|
||||
_write_setting(
|
||||
{
|
||||
"hash": hash_opaque_token(token),
|
||||
"issued_at": datetime.now(tz=timezone.utc).isoformat(),
|
||||
"expires_at": None, # never expires until consumed
|
||||
"consumed_at": None,
|
||||
}
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def regenerate_install_token() -> str:
|
||||
"""CLI helper: always mint and persist a fresh token (overwrites any pending one)."""
|
||||
return ensure_install_token(force=True) or _force_mint()
|
||||
|
||||
|
||||
def _force_mint() -> str:
|
||||
token = generate_opaque_token()
|
||||
_write_setting(
|
||||
{
|
||||
"hash": hash_opaque_token(token),
|
||||
"issued_at": datetime.now(tz=timezone.utc).isoformat(),
|
||||
"expires_at": None,
|
||||
"consumed_at": None,
|
||||
}
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def verify_install_token(token: str) -> bool:
|
||||
"""Constant-time comparison against the stored hash."""
|
||||
setting = _read_setting()
|
||||
if setting is None or not setting.value:
|
||||
return False
|
||||
payload = setting.value
|
||||
if payload.get("consumed_at"):
|
||||
return False
|
||||
expected = payload.get("hash")
|
||||
if not expected:
|
||||
return False
|
||||
import hmac
|
||||
|
||||
return hmac.compare_digest(hash_opaque_token(token), expected)
|
||||
|
||||
|
||||
def mark_install_token_consumed() -> None:
|
||||
setting = _read_setting()
|
||||
if setting is None:
|
||||
return
|
||||
payload = dict(setting.value or {})
|
||||
payload["consumed_at"] = datetime.now(tz=timezone.utc).isoformat()
|
||||
_write_setting(payload)
|
||||
|
||||
|
||||
def log_install_token_banner(raw_token: str) -> None:
|
||||
"""Pretty banner so the token is unmissable in container logs."""
|
||||
sep = "=" * 72
|
||||
log.warning(
|
||||
"metamorph.install_token.minted",
|
||||
extra={
|
||||
"banner": sep,
|
||||
"message_template": (
|
||||
"BOOTSTRAP — copy the token below and POST it to /api/v1/setup "
|
||||
"with your desired admin email + password. Save it: it is logged once."
|
||||
),
|
||||
"install_token": raw_token,
|
||||
},
|
||||
)
|
||||
# Also dump a plain banner so the token is grep-friendly even if the JSON
|
||||
# consumer hides `extra` fields.
|
||||
print(sep, flush=True) # noqa: T201
|
||||
print(f"INSTALL TOKEN: {raw_token}", flush=True) # noqa: T201
|
||||
print(sep, flush=True) # noqa: T201
|
||||
97
backend/app/core/jwt_tokens.py
Normal file
97
backend/app/core/jwt_tokens.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""JWT encoding / decoding.
|
||||
|
||||
Two token types:
|
||||
- `access` — short-lived (1 h), in `Authorization: Bearer ...` headers, kept
|
||||
client-side **in memory** only (cf. spec §M2).
|
||||
- `refresh` — long-lived (30 d), in an HTTPOnly Secure SameSite=Strict cookie
|
||||
scoped to `/api/v1/auth/`. Rotated on every successful refresh,
|
||||
old `jti` revoked.
|
||||
|
||||
We sign HS256 with `settings.JWT_SECRET`. The `jti` claim links each token to
|
||||
its DB row in `refresh_tokens` for revocation; access tokens are stateless.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Literal
|
||||
|
||||
import jwt
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
ACCESS_TOKEN_TTL = timedelta(hours=1)
|
||||
REFRESH_TOKEN_TTL = timedelta(days=30)
|
||||
ALGORITHM = "HS256"
|
||||
ISSUER = "metamorph"
|
||||
|
||||
|
||||
TokenType = Literal["access", "refresh"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TokenClaims:
|
||||
sub: str # user id (UUID as string)
|
||||
type: TokenType
|
||||
jti: str
|
||||
iat: datetime
|
||||
exp: datetime
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(tz=timezone.utc)
|
||||
|
||||
|
||||
def generate_jti() -> str:
|
||||
"""Compact, URL-safe random identifier (≈22 chars)."""
|
||||
return secrets.token_urlsafe(16)
|
||||
|
||||
|
||||
def encode_token(
|
||||
user_id: uuid.UUID | str,
|
||||
token_type: TokenType,
|
||||
*,
|
||||
jti: str | None = None,
|
||||
) -> tuple[str, TokenClaims]:
|
||||
"""Return `(jwt_string, claims)`. `jti` is generated if not provided."""
|
||||
now = _now()
|
||||
ttl = ACCESS_TOKEN_TTL if token_type == "access" else REFRESH_TOKEN_TTL
|
||||
claims = TokenClaims(
|
||||
sub=str(user_id),
|
||||
type=token_type,
|
||||
jti=jti or generate_jti(),
|
||||
iat=now,
|
||||
exp=now + ttl,
|
||||
)
|
||||
payload = {
|
||||
"iss": ISSUER,
|
||||
"sub": claims.sub,
|
||||
"type": claims.type,
|
||||
"jti": claims.jti,
|
||||
"iat": int(claims.iat.timestamp()),
|
||||
"exp": int(claims.exp.timestamp()),
|
||||
}
|
||||
return jwt.encode(payload, settings.JWT_SECRET, algorithm=ALGORITHM), claims
|
||||
|
||||
|
||||
def decode_token(token: str, *, expected_type: TokenType) -> TokenClaims:
|
||||
"""Decode and validate a JWT. Raises `jwt.PyJWTError` on any failure."""
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.JWT_SECRET,
|
||||
algorithms=[ALGORITHM],
|
||||
issuer=ISSUER,
|
||||
options={"require": ["sub", "type", "jti", "iat", "exp"]},
|
||||
)
|
||||
if payload["type"] != expected_type:
|
||||
raise jwt.InvalidTokenError(f"expected {expected_type} token, got {payload['type']}")
|
||||
return TokenClaims(
|
||||
sub=payload["sub"],
|
||||
type=payload["type"],
|
||||
jti=payload["jti"],
|
||||
iat=datetime.fromtimestamp(payload["iat"], tz=timezone.utc),
|
||||
exp=datetime.fromtimestamp(payload["exp"], tz=timezone.utc),
|
||||
)
|
||||
29
backend/app/core/rate_limit.py
Normal file
29
backend/app/core/rate_limit.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Shared flask-limiter instance.
|
||||
|
||||
Anchored on remote address. In-memory backend for v1 (single-process gunicorn
|
||||
worker pool can drift; that's acceptable at this scale). M14 will switch to
|
||||
Redis if it becomes a real concern.
|
||||
|
||||
The limiter is enforced in `APP_ENV in ("prod", "staging")` — dev and test
|
||||
deployments share an in-memory backend that's noisy across hot-reloads and
|
||||
would gate the Playwright e2e suite at 10 req/min/IP. The spec NF-security
|
||||
requirement is explicitly a *production* one (cf. tasks/spec.md §6
|
||||
NF-security); a staging deployment is exposed to humans so the same limits
|
||||
apply there.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address,
|
||||
default_limits=[],
|
||||
storage_uri="memory://",
|
||||
headers_enabled=True,
|
||||
strategy="fixed-window",
|
||||
enabled=settings.APP_ENV in ("prod", "staging"),
|
||||
)
|
||||
62
backend/app/core/security.py
Normal file
62
backend/app/core/security.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Password hashing and constant-time secret hashing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
|
||||
# Argon2id with moderate cost. `time_cost=2`, `memory_cost=64MiB`, `parallelism=2`
|
||||
# is well above OWASP minimums while staying snappy on a Debian small VM.
|
||||
_hasher = PasswordHasher(
|
||||
time_cost=2,
|
||||
memory_cost=64 * 1024,
|
||||
parallelism=2,
|
||||
hash_len=32,
|
||||
salt_len=16,
|
||||
)
|
||||
|
||||
|
||||
def hash_password(plaintext: str) -> str:
|
||||
return _hasher.hash(plaintext)
|
||||
|
||||
|
||||
def verify_password(stored_hash: str, plaintext: str) -> bool:
|
||||
"""Constant-time verification. Returns False on mismatch, never raises."""
|
||||
try:
|
||||
return _hasher.verify(stored_hash, plaintext)
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
except Exception: # corrupted hash or unsupported parameters
|
||||
return False
|
||||
|
||||
|
||||
def needs_rehash(stored_hash: str) -> bool:
|
||||
"""True when Argon2 parameters have evolved since the hash was created."""
|
||||
try:
|
||||
return _hasher.check_needs_rehash(stored_hash)
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
# === Opaque-token helpers (refresh tokens, invitation tokens) ===
|
||||
#
|
||||
# We never store the raw token in DB — only its SHA-256. Comparison uses
|
||||
# `hmac.compare_digest` to dodge timing attacks. Tokens are URL-safe base64.
|
||||
|
||||
TOKEN_BYTES = 48 # 384 bits of entropy → 64 chars b64url
|
||||
|
||||
|
||||
def generate_opaque_token() -> str:
|
||||
return secrets.token_urlsafe(TOKEN_BYTES)
|
||||
|
||||
|
||||
def hash_opaque_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def verify_opaque_token(token: str, stored_hash: str) -> bool:
|
||||
return hmac.compare_digest(hash_opaque_token(token), stored_hash)
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
224
backend/app/services/auth.py
Normal file
224
backend/app/services/auth.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""Auth domain logic: login, refresh rotation, logout, change_password.
|
||||
|
||||
Returns lightweight DTOs (dicts) — the API layer is responsible for HTTP shape.
|
||||
Raises plain `ValueError` / `LookupError` / `PermissionError` and lets the API
|
||||
layer translate them into HTTP statuses.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.jwt_tokens import (
|
||||
REFRESH_TOKEN_TTL,
|
||||
decode_token,
|
||||
encode_token,
|
||||
generate_jti,
|
||||
)
|
||||
from app.core.security import (
|
||||
hash_opaque_token,
|
||||
hash_password,
|
||||
needs_rehash,
|
||||
verify_opaque_token,
|
||||
verify_password,
|
||||
)
|
||||
from app.db.session import session_scope
|
||||
from app.models.auth import RefreshToken, User
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
"""Base for auth-flow exceptions; HTTP layer maps to 401/403."""
|
||||
|
||||
|
||||
class InvalidCredentials(AuthError):
|
||||
pass
|
||||
|
||||
|
||||
class TokenRevoked(AuthError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenPair:
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
refresh_expires_at: datetime
|
||||
user_id: uuid.UUID
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(tz=timezone.utc)
|
||||
|
||||
|
||||
# === Login ===================================================================
|
||||
|
||||
|
||||
def login(email: str, password: str) -> TokenPair:
|
||||
email_norm = email.strip().lower()
|
||||
with session_scope() as s:
|
||||
user = s.scalar(
|
||||
select(User).where(
|
||||
User.email == email_norm,
|
||||
User.deleted_at.is_(None),
|
||||
User.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
if user is None or not verify_password(user.password_hash, password):
|
||||
# Same error for "no such user" and "wrong password" — no account enumeration.
|
||||
raise InvalidCredentials("invalid credentials")
|
||||
|
||||
if needs_rehash(user.password_hash):
|
||||
user.password_hash = hash_password(password)
|
||||
|
||||
return _issue_token_pair(s, user.id)
|
||||
|
||||
|
||||
# === Refresh rotation ========================================================
|
||||
|
||||
|
||||
def refresh(raw_refresh_token: str) -> TokenPair:
|
||||
"""Validate the refresh token, revoke the old one, mint a new pair.
|
||||
|
||||
Detects token reuse: if a refresh token that has already been rotated is
|
||||
presented again, we revoke the entire chain (treat as compromise).
|
||||
"""
|
||||
try:
|
||||
claims = decode_token(raw_refresh_token, expected_type="refresh")
|
||||
except Exception as e:
|
||||
raise InvalidCredentials("invalid refresh token") from e
|
||||
|
||||
token_hash = hash_opaque_token(raw_refresh_token)
|
||||
|
||||
with session_scope() as s:
|
||||
rt = s.scalar(
|
||||
select(RefreshToken).where(
|
||||
RefreshToken.jti == claims.jti,
|
||||
RefreshToken.token_hash == token_hash,
|
||||
)
|
||||
)
|
||||
if rt is None:
|
||||
raise InvalidCredentials("refresh token not recognised")
|
||||
|
||||
if rt.revoked_at is not None:
|
||||
# Reuse of a revoked token → likely compromise. Cascade-revoke chain.
|
||||
_revoke_chain(s, rt)
|
||||
raise TokenRevoked("refresh token has been revoked")
|
||||
|
||||
if rt.expires_at <= _now():
|
||||
raise InvalidCredentials("refresh token expired")
|
||||
|
||||
# Rotate: mark old as revoked + replaced_by, mint new.
|
||||
new_pair = _issue_token_pair(s, rt.user_id)
|
||||
new_jti = decode_token(new_pair.refresh_token, expected_type="refresh").jti
|
||||
new_rt = s.scalar(select(RefreshToken).where(RefreshToken.jti == new_jti))
|
||||
rt.revoked_at = _now()
|
||||
rt.replaced_by_id = new_rt.id if new_rt else None
|
||||
return new_pair
|
||||
|
||||
|
||||
# === Logout ==================================================================
|
||||
|
||||
|
||||
def logout(raw_refresh_token: str) -> None:
|
||||
"""Revoke the refresh token. Idempotent — silently no-ops on bad tokens."""
|
||||
try:
|
||||
claims = decode_token(raw_refresh_token, expected_type="refresh")
|
||||
except Exception:
|
||||
return
|
||||
with session_scope() as s:
|
||||
rt = s.scalar(select(RefreshToken).where(RefreshToken.jti == claims.jti))
|
||||
if rt is not None and rt.revoked_at is None:
|
||||
rt.revoked_at = _now()
|
||||
|
||||
|
||||
def logout_all_for_user(user_id: uuid.UUID) -> int:
|
||||
"""Revoke every active refresh token for a user. Returns count revoked."""
|
||||
now = _now()
|
||||
with session_scope() as s:
|
||||
active = s.scalars(
|
||||
select(RefreshToken).where(
|
||||
RefreshToken.user_id == user_id,
|
||||
RefreshToken.revoked_at.is_(None),
|
||||
)
|
||||
).all()
|
||||
for rt in active:
|
||||
rt.revoked_at = now
|
||||
return len(active)
|
||||
|
||||
|
||||
# === Password change ========================================================
|
||||
|
||||
|
||||
def change_password(user_id: uuid.UUID, current: str, new: str) -> None:
|
||||
if len(new) < 8:
|
||||
raise ValueError("new password must be at least 8 characters")
|
||||
with session_scope() as s:
|
||||
user = s.get(User, user_id)
|
||||
if user is None or user.deleted_at is not None or not user.is_active:
|
||||
raise LookupError("user not found")
|
||||
if not verify_password(user.password_hash, current):
|
||||
raise InvalidCredentials("current password is incorrect")
|
||||
user.password_hash = hash_password(new)
|
||||
# Force re-login on every other device.
|
||||
logout_all_for_user(user_id)
|
||||
|
||||
|
||||
# === Helpers =================================================================
|
||||
|
||||
|
||||
def _issue_token_pair(s, user_id: uuid.UUID) -> TokenPair:
|
||||
"""Issue a fresh access + refresh pair. The refresh row is persisted."""
|
||||
access_jti = generate_jti()
|
||||
refresh_jti = generate_jti()
|
||||
access_token, _ = encode_token(user_id, "access", jti=access_jti)
|
||||
refresh_token, refresh_claims = encode_token(user_id, "refresh", jti=refresh_jti)
|
||||
|
||||
s.add(
|
||||
RefreshToken(
|
||||
user_id=user_id,
|
||||
jti=refresh_jti,
|
||||
token_hash=hash_opaque_token(refresh_token),
|
||||
issued_at=refresh_claims.iat,
|
||||
expires_at=refresh_claims.exp,
|
||||
)
|
||||
)
|
||||
s.flush() # ensure the row gets an id before we return
|
||||
|
||||
return TokenPair(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
refresh_expires_at=refresh_claims.exp,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
def _revoke_chain(s, rt: RefreshToken) -> None:
|
||||
"""When reuse is detected, revoke this token and its replacement chain."""
|
||||
seen: set[uuid.UUID] = set()
|
||||
cur: RefreshToken | None = rt
|
||||
while cur is not None and cur.id not in seen:
|
||||
seen.add(cur.id)
|
||||
if cur.revoked_at is None:
|
||||
cur.revoked_at = _now()
|
||||
if cur.replaced_by_id:
|
||||
cur = s.get(RefreshToken, cur.replaced_by_id)
|
||||
else:
|
||||
cur = None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AuthError",
|
||||
"InvalidCredentials",
|
||||
"TokenRevoked",
|
||||
"TokenPair",
|
||||
"REFRESH_TOKEN_TTL",
|
||||
"login",
|
||||
"refresh",
|
||||
"logout",
|
||||
"logout_all_for_user",
|
||||
"change_password",
|
||||
]
|
||||
98
backend/app/services/bootstrap.py
Normal file
98
backend/app/services/bootstrap.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Initial bootstrap : seed `admin` / `redteam` / `blueteam` system groups + first admin.
|
||||
|
||||
The detailed permission seeding lives in M3 (`mitre.sync` etc.); for M2 we only
|
||||
need an `admin` group that effectively grants full access. We model that as an
|
||||
absent permission set + a special `is_system` flag on the group, plus the
|
||||
`@require_perm` decorator that bypasses checks for any user belonging to a
|
||||
system `admin` group. M3 will fill in the atomic permissions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.install_token import (
|
||||
mark_install_token_consumed,
|
||||
verify_install_token,
|
||||
)
|
||||
from app.core.security import hash_password
|
||||
from app.db.session import session_scope
|
||||
from app.models.auth import Group, User, UserGroup
|
||||
|
||||
ADMIN_GROUP_NAME = "admin"
|
||||
REDTEAM_GROUP_NAME = "redteam"
|
||||
BLUETEAM_GROUP_NAME = "blueteam"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BootstrapResult:
|
||||
user_id: uuid.UUID
|
||||
admin_group_id: uuid.UUID
|
||||
|
||||
|
||||
class BootstrapError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def ensure_system_groups() -> dict[str, uuid.UUID]:
|
||||
"""Create the three system groups if missing. Idempotent."""
|
||||
out: dict[str, uuid.UUID] = {}
|
||||
with session_scope() as s:
|
||||
for name, desc in (
|
||||
(ADMIN_GROUP_NAME, "Platform administrators — full access."),
|
||||
(REDTEAM_GROUP_NAME, "Red team operators."),
|
||||
(BLUETEAM_GROUP_NAME, "Blue team operators."),
|
||||
):
|
||||
grp = s.scalar(select(Group).where(Group.name == name, Group.is_system.is_(True)))
|
||||
if grp is None:
|
||||
grp = Group(name=name, description=desc, is_system=True)
|
||||
s.add(grp)
|
||||
s.flush()
|
||||
out[name] = grp.id
|
||||
return out
|
||||
|
||||
|
||||
def bootstrap_admin(
|
||||
*, install_token: str, email: str, password: str, display_name: str | None = None
|
||||
) -> BootstrapResult:
|
||||
"""Consume the install token, create the first admin user, attach to admin group."""
|
||||
if not verify_install_token(install_token):
|
||||
raise BootstrapError("invalid or already-consumed install token")
|
||||
if len(password) < 8:
|
||||
raise ValueError("password must be at least 8 characters")
|
||||
|
||||
email_norm = email.strip().lower()
|
||||
|
||||
# Re-check users count under transaction to avoid races.
|
||||
with session_scope() as s:
|
||||
if s.scalar(select(User.id).limit(1)) is not None:
|
||||
raise BootstrapError("setup already done — at least one user exists")
|
||||
|
||||
groups = ensure_system_groups()
|
||||
|
||||
with session_scope() as s:
|
||||
user = User(
|
||||
email=email_norm,
|
||||
display_name=(display_name or "").strip() or None,
|
||||
password_hash=hash_password(password),
|
||||
)
|
||||
s.add(user)
|
||||
s.flush()
|
||||
s.add(UserGroup(user_id=user.id, group_id=groups[ADMIN_GROUP_NAME]))
|
||||
admin_id = groups[ADMIN_GROUP_NAME]
|
||||
user_id = user.id
|
||||
|
||||
mark_install_token_consumed()
|
||||
|
||||
# Re-seed the permission catalogue + system-group bindings. This is called
|
||||
# at boot too, but on a fresh DB after `/diag/reset` the groups were just
|
||||
# recreated above and have no permissions yet — seeding here keeps the
|
||||
# bootstrap path self-contained.
|
||||
from app.services.permissions_seed import seed_all # noqa: PLC0415 — avoid import cycle
|
||||
|
||||
seed_all()
|
||||
|
||||
return BootstrapResult(user_id=user_id, admin_group_id=admin_id)
|
||||
188
backend/app/services/invitations.py
Normal file
188
backend/app/services/invitations.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Invitation flow: admin issues a one-shot URL token, invitee accepts.
|
||||
|
||||
The raw token is shown to the admin once (returned by `create_invitation`)
|
||||
and never persisted — only its SHA-256 lives in the DB. Pre-assigned groups
|
||||
are attached at creation and applied at acceptance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Iterable
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.security import (
|
||||
generate_opaque_token,
|
||||
hash_opaque_token,
|
||||
hash_password,
|
||||
)
|
||||
from app.db.session import session_scope
|
||||
from app.models.auth import Group, Invitation, InvitationGroup, User, UserGroup
|
||||
|
||||
INVITATION_TTL = timedelta(days=7)
|
||||
|
||||
|
||||
class InvitationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvitationExpired(InvitationError):
|
||||
pass
|
||||
|
||||
|
||||
class InvitationConsumed(InvitationError):
|
||||
pass
|
||||
|
||||
|
||||
class InvitationRevoked(InvitationError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class InvitationCreated:
|
||||
invitation_id: uuid.UUID
|
||||
raw_token: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class InvitationPreview:
|
||||
email_hint: str | None
|
||||
expires_at: datetime
|
||||
groups: list[str]
|
||||
is_valid: bool
|
||||
reason: str | None
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(tz=timezone.utc)
|
||||
|
||||
|
||||
def create_invitation(
|
||||
*,
|
||||
created_by_user_id: uuid.UUID,
|
||||
email_hint: str | None,
|
||||
group_ids: Iterable[uuid.UUID] = (),
|
||||
ttl: timedelta = INVITATION_TTL,
|
||||
) -> InvitationCreated:
|
||||
raw = generate_opaque_token()
|
||||
expires_at = _now() + ttl
|
||||
with session_scope() as s:
|
||||
inv = Invitation(
|
||||
token_hash=hash_opaque_token(raw),
|
||||
email_hint=email_hint.strip().lower() if email_hint else None,
|
||||
created_by_user_id=created_by_user_id,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
s.add(inv)
|
||||
s.flush()
|
||||
for gid in group_ids:
|
||||
s.add(InvitationGroup(invitation_id=inv.id, group_id=gid))
|
||||
return InvitationCreated(
|
||||
invitation_id=inv.id,
|
||||
raw_token=raw,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
|
||||
def _load_by_token(s, raw_token: str) -> Invitation | None:
|
||||
return s.scalar(
|
||||
select(Invitation).where(Invitation.token_hash == hash_opaque_token(raw_token))
|
||||
)
|
||||
|
||||
|
||||
def preview(raw_token: str) -> InvitationPreview:
|
||||
with session_scope() as s:
|
||||
inv = _load_by_token(s, raw_token)
|
||||
if inv is None:
|
||||
return InvitationPreview(None, _now(), [], False, "not_found")
|
||||
groups = [g.name for g in inv.pre_assigned_groups]
|
||||
if inv.revoked_at is not None:
|
||||
return InvitationPreview(inv.email_hint, inv.expires_at, groups, False, "revoked")
|
||||
if inv.consumed_at is not None:
|
||||
return InvitationPreview(inv.email_hint, inv.expires_at, groups, False, "consumed")
|
||||
if inv.expires_at <= _now():
|
||||
return InvitationPreview(inv.email_hint, inv.expires_at, groups, False, "expired")
|
||||
return InvitationPreview(inv.email_hint, inv.expires_at, groups, True, None)
|
||||
|
||||
|
||||
def accept(raw_token: str, *, email: str, password: str, display_name: str | None) -> uuid.UUID:
|
||||
"""Create the user, attach pre-assigned groups, mark invitation consumed."""
|
||||
if len(password) < 8:
|
||||
raise ValueError("password must be at least 8 characters")
|
||||
|
||||
email_norm = email.strip().lower()
|
||||
with session_scope() as s:
|
||||
inv = _load_by_token(s, raw_token)
|
||||
if inv is None:
|
||||
raise InvitationError("invitation not found")
|
||||
if inv.revoked_at is not None:
|
||||
raise InvitationRevoked("invitation revoked")
|
||||
if inv.consumed_at is not None:
|
||||
raise InvitationConsumed("invitation already consumed")
|
||||
if inv.expires_at <= _now():
|
||||
raise InvitationExpired("invitation expired")
|
||||
|
||||
# Email must not be already in use among active users.
|
||||
existing = s.scalar(
|
||||
select(User).where(User.email == email_norm, User.deleted_at.is_(None))
|
||||
)
|
||||
if existing is not None:
|
||||
raise ValueError("email already in use")
|
||||
|
||||
user = User(
|
||||
email=email_norm,
|
||||
display_name=(display_name or "").strip() or None,
|
||||
password_hash=hash_password(password),
|
||||
)
|
||||
s.add(user)
|
||||
s.flush()
|
||||
|
||||
for grp in inv.pre_assigned_groups:
|
||||
s.add(UserGroup(user_id=user.id, group_id=grp.id))
|
||||
|
||||
inv.consumed_at = _now()
|
||||
inv.consumed_by_user_id = user.id
|
||||
return user.id
|
||||
|
||||
|
||||
def revoke(invitation_id: uuid.UUID) -> bool:
|
||||
with session_scope() as s:
|
||||
inv = s.get(Invitation, invitation_id)
|
||||
if inv is None:
|
||||
return False
|
||||
if inv.revoked_at is not None or inv.consumed_at is not None:
|
||||
return False
|
||||
inv.revoked_at = _now()
|
||||
return True
|
||||
|
||||
|
||||
def list_active(*, limit: int = 100) -> list[Invitation]:
|
||||
with session_scope() as s:
|
||||
rows = s.scalars(
|
||||
select(Invitation)
|
||||
.where(
|
||||
Invitation.consumed_at.is_(None),
|
||||
Invitation.revoked_at.is_(None),
|
||||
Invitation.expires_at > _now(),
|
||||
)
|
||||
.order_by(Invitation.created_at.desc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
# detach so caller can read after session closes
|
||||
for r in rows:
|
||||
s.expunge(r)
|
||||
for g in r.pre_assigned_groups:
|
||||
s.expunge(g)
|
||||
return list(rows)
|
||||
|
||||
|
||||
def find_group_id_by_name(name: str) -> uuid.UUID | None:
|
||||
with session_scope() as s:
|
||||
gid = s.scalar(
|
||||
select(Group.id).where(Group.name == name, Group.deleted_at.is_(None))
|
||||
)
|
||||
return gid
|
||||
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"])
|
||||
167
e2e/tests/m2-auth.spec.ts
Normal file
167
e2e/tests/m2-auth.spec.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
76
frontend/src/components/Layout.tsx
Normal file
76
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
export function Layout() {
|
||||
const { state, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const navItem = (to: string, label: string) => (
|
||||
<NavLink
|
||||
to={to}
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'font-mono text-xs uppercase tracking-wider2 px-3 py-1 rounded',
|
||||
isActive ? 'text-cyan accent-fill-cyan' : 'text-text-dim hover:text-text-bright',
|
||||
)
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="px-[60px] py-10">
|
||||
<div className="mx-auto max-w-page">
|
||||
<header className="flex items-baseline justify-between mb-12">
|
||||
<Link to="/" className="font-mono text-[24px] font-bold tracking-tight text-text-bright">
|
||||
<span className="text-red">Meta</span>
|
||||
<span>morph</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-2" aria-label="Primary">
|
||||
{state.user ? (
|
||||
<>
|
||||
{navItem('/', 'Home')}
|
||||
{navItem('/profile', 'Profile')}
|
||||
{state.user.is_admin && (
|
||||
<>
|
||||
{navItem('/admin/users', 'Users')}
|
||||
{navItem('/admin/groups', 'Groups')}
|
||||
{navItem('/admin/invitations', 'Invitations')}
|
||||
</>
|
||||
)}
|
||||
<span className="font-mono text-2xs text-text-dim ml-2" data-testid="me-email">
|
||||
{state.user.email}
|
||||
</span>
|
||||
<Button
|
||||
accent="rose"
|
||||
onClick={async () => {
|
||||
await logout();
|
||||
navigate('/login', { replace: true });
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{navItem('/login', 'Login')}
|
||||
{navItem('/setup', 'Setup')}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<Outlet />
|
||||
|
||||
<footer className="mt-[60px] py-8 border-t border-border text-center font-mono text-xs text-text-dim">
|
||||
metamorph · M0 bootstrap · M1 db schema · M2 auth · M3 rbac · design system from tasks/design.md
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
frontend/src/components/RequireAuth.tsx
Normal file
16
frontend/src/components/RequireAuth.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
export function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const { state } = useAuth();
|
||||
const loc = useLocation();
|
||||
if (state.loading) {
|
||||
return <p className="font-mono text-xs text-text-dim p-8">Loading session…</p>;
|
||||
}
|
||||
if (!state.user) {
|
||||
return <Navigate to="/login" state={{ from: loc.pathname }} replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
39
frontend/src/components/ui/Alert.tsx
Normal file
39
frontend/src/components/ui/Alert.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { cn, type Accent } from '@/lib/cn';
|
||||
|
||||
interface AlertProps {
|
||||
accent: Accent;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
/** Optional ARIA role override; defaults to "alert" for errors. */
|
||||
role?: string;
|
||||
}
|
||||
|
||||
const ACCENT_FILL: Record<Accent, string> = {
|
||||
red: 'accent-fill-red',
|
||||
orange: 'accent-fill-orange',
|
||||
yellow: 'accent-fill-yellow',
|
||||
green: 'accent-fill-green',
|
||||
cyan: 'accent-fill-cyan',
|
||||
blue: 'accent-fill-blue',
|
||||
purple: 'accent-fill-purple',
|
||||
pink: 'accent-fill-pink',
|
||||
rose: 'accent-fill-rose',
|
||||
teal: 'accent-fill-teal',
|
||||
};
|
||||
|
||||
export function Alert({ accent, children, className, role }: AlertProps) {
|
||||
return (
|
||||
<div
|
||||
role={role ?? (accent === 'red' || accent === 'rose' ? 'alert' : 'status')}
|
||||
className={cn(
|
||||
'rounded-md border border-current/30 px-3 py-2 font-mono text-xs',
|
||||
ACCENT_FILL[accent],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
frontend/src/components/ui/TextField.tsx
Normal file
48
frontend/src/components/ui/TextField.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { forwardRef, useId, type InputHTMLAttributes } from 'react';
|
||||
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label: string;
|
||||
hint?: string;
|
||||
errorText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Form field with explicit label/input association via `htmlFor` / `id`.
|
||||
* The hint and error text are rendered as siblings, NOT inside the `<label>`,
|
||||
* so the accessible name of the input remains exactly the `label` prop —
|
||||
* crucial for `getByLabel(...)` selectors in Playwright.
|
||||
*/
|
||||
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(function TextField(
|
||||
{ label, hint, errorText, className, id, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const fallbackId = useId();
|
||||
const inputId = id ?? fallbackId;
|
||||
return (
|
||||
<div className="block">
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={cn(
|
||||
'mt-1 w-full rounded-md border bg-bg-card px-3 py-2 font-mono text-xs text-text-bright placeholder:text-text-dim',
|
||||
errorText ? 'border-red' : 'border-border focus:border-cyan',
|
||||
'focus:outline-none',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
{hint && !errorText && (
|
||||
<p className="mt-1 font-mono text-2xs text-text-dim">{hint}</p>
|
||||
)}
|
||||
{errorText && <p className="mt-1 font-mono text-2xs text-red">{errorText}</p>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
141
frontend/src/lib/api.ts
Normal file
141
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Auth-aware fetch wrapper.
|
||||
*
|
||||
* Strategy:
|
||||
* - Access token kept in module memory (never in localStorage).
|
||||
* - Refresh token lives in an HTTPOnly cookie set by the backend.
|
||||
* - On 401, we try ONE silent refresh via /auth/refresh, retry, then give up.
|
||||
*
|
||||
* Consumers go through `apiFetch`, `apiGet`, `apiPost` helpers.
|
||||
*/
|
||||
|
||||
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? '/api/v1';
|
||||
|
||||
type AccessTokenListener = (token: string | null) => void;
|
||||
|
||||
let accessToken: string | null = null;
|
||||
const listeners = new Set<AccessTokenListener>();
|
||||
|
||||
export function setAccessToken(t: string | null) {
|
||||
accessToken = t;
|
||||
for (const l of listeners) l(t);
|
||||
}
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
export function onAccessTokenChange(fn: AccessTokenListener): () => void {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
/** Returns true if the refresh succeeded and `accessToken` is updated. */
|
||||
async function silentRefresh(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const body = (await res.json()) as { access_token: string };
|
||||
if (!body.access_token) return false;
|
||||
setAccessToken(body.access_token);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiOptions extends RequestInit {
|
||||
/** Skip the silent-refresh path — used by /auth/* endpoints themselves. */
|
||||
noRefresh?: boolean;
|
||||
/** Skip Authorization header — used for /setup and unauthenticated probes. */
|
||||
anonymous?: boolean;
|
||||
}
|
||||
|
||||
export async function apiFetch(path: string, opts: ApiOptions = {}): Promise<Response> {
|
||||
const headers = new Headers(opts.headers);
|
||||
headers.set('Accept', 'application/json');
|
||||
if (!headers.has('Content-Type') && opts.body && !(opts.body instanceof FormData)) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
if (!opts.anonymous && accessToken) {
|
||||
headers.set('Authorization', `Bearer ${accessToken}`);
|
||||
}
|
||||
|
||||
let res = await fetch(`${BASE_URL}${path}`, {
|
||||
...opts,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (res.status === 401 && !opts.noRefresh && !opts.anonymous) {
|
||||
const refreshed = await silentRefresh();
|
||||
if (refreshed) {
|
||||
headers.set('Authorization', `Bearer ${accessToken}`);
|
||||
res = await fetch(`${BASE_URL}${path}`, {
|
||||
...opts,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
});
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public payload: unknown,
|
||||
message?: string,
|
||||
) {
|
||||
super(message ?? `HTTP ${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function parseOrThrow<T>(res: Response): Promise<T> {
|
||||
if (res.status === 204) return undefined as T;
|
||||
const isJson = (res.headers.get('content-type') ?? '').includes('application/json');
|
||||
const body = isJson ? await res.json() : await res.text();
|
||||
if (!res.ok) throw new ApiError(res.status, body);
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export async function apiGet<T>(path: string, opts?: ApiOptions): Promise<T> {
|
||||
return parseOrThrow<T>(await apiFetch(path, { ...opts, method: 'GET' }));
|
||||
}
|
||||
|
||||
export async function apiPost<T>(path: string, body?: unknown, opts?: ApiOptions): Promise<T> {
|
||||
return parseOrThrow<T>(
|
||||
await apiFetch(path, {
|
||||
...opts,
|
||||
method: 'POST',
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiPatch<T>(path: string, body?: unknown, opts?: ApiOptions): Promise<T> {
|
||||
return parseOrThrow<T>(
|
||||
await apiFetch(path, {
|
||||
...opts,
|
||||
method: 'PATCH',
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiPut<T>(path: string, body?: unknown, opts?: ApiOptions): Promise<T> {
|
||||
return parseOrThrow<T>(
|
||||
await apiFetch(path, {
|
||||
...opts,
|
||||
method: 'PUT',
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiDelete<T>(path: string, opts?: ApiOptions): Promise<T> {
|
||||
return parseOrThrow<T>(await apiFetch(path, { ...opts, method: 'DELETE' }));
|
||||
}
|
||||
125
frontend/src/lib/auth.ts
Normal file
125
frontend/src/lib/auth.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Auth state hook + login/logout helpers.
|
||||
*
|
||||
* The hook tries a silent /auth/refresh on mount so a returning user sees
|
||||
* "logged in" without typing credentials again. If that fails, the SPA
|
||||
* renders /login.
|
||||
*/
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { apiPost, getAccessToken, onAccessTokenChange, setAccessToken } from '@/lib/api';
|
||||
|
||||
export interface MeResponse {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
locale: string;
|
||||
is_admin: boolean;
|
||||
groups: string[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
loading: boolean;
|
||||
user: MeResponse | null;
|
||||
hasAccessToken: boolean;
|
||||
}
|
||||
|
||||
export interface AuthApi {
|
||||
state: AuthState;
|
||||
login: (email: string, password: string) => Promise<MeResponse>;
|
||||
logout: () => Promise<void>;
|
||||
reload: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
access_token: string;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
async function fetchMe(): Promise<MeResponse> {
|
||||
const { apiGet } = await import('@/lib/api');
|
||||
return apiGet<MeResponse>('/auth/me');
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthApi | null>(null);
|
||||
|
||||
export function useAuth(): AuthApi {
|
||||
const v = useContext(AuthContext);
|
||||
if (!v) throw new Error('useAuth must be used inside <AuthProvider>');
|
||||
return v;
|
||||
}
|
||||
|
||||
export function useProvideAuth(): AuthApi {
|
||||
const [user, setUser] = useState<MeResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasToken, setHasToken] = useState<boolean>(getAccessToken() !== null);
|
||||
|
||||
useEffect(() => onAccessTokenChange((t) => setHasToken(t !== null)), []);
|
||||
|
||||
const reload = async () => {
|
||||
if (!getAccessToken()) {
|
||||
// Try a silent refresh — if a refresh cookie exists from a prior session.
|
||||
try {
|
||||
const r = await apiPost<{ access_token: string }>('/auth/refresh', undefined, {
|
||||
noRefresh: true,
|
||||
anonymous: true,
|
||||
});
|
||||
if (r.access_token) setAccessToken(r.access_token);
|
||||
} catch {
|
||||
/* no cookie, no big deal */
|
||||
}
|
||||
}
|
||||
if (!getAccessToken()) {
|
||||
setUser(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setUser(await fetchMe());
|
||||
} catch {
|
||||
setAccessToken(null);
|
||||
setUser(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
await reload();
|
||||
if (!cancelled) setLoading(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const login = async (email: string, password: string): Promise<MeResponse> => {
|
||||
const r = await apiPost<LoginResponse>(
|
||||
'/auth/login',
|
||||
{ email, password },
|
||||
{ noRefresh: true, anonymous: true },
|
||||
);
|
||||
setAccessToken(r.access_token);
|
||||
const me = await fetchMe();
|
||||
setUser(me);
|
||||
return me;
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await apiPost('/auth/logout', undefined, { noRefresh: true });
|
||||
} catch {
|
||||
/* idempotent */
|
||||
}
|
||||
setAccessToken(null);
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return {
|
||||
state: { loading, user, hasAccessToken: hasToken },
|
||||
login,
|
||||
logout,
|
||||
reload,
|
||||
};
|
||||
}
|
||||
95
frontend/src/pages/LoginPage.tsx
Normal file
95
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Alert } from '@/components/ui/Alert';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { SectionHeader } from '@/components/ui/SectionHeader';
|
||||
import { TextField } from '@/components/ui/TextField';
|
||||
import { ApiError, apiGet } from '@/lib/api';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
interface SetupStatus {
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [setupNeeded, setSetupNeeded] = useState(false);
|
||||
const emailRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiGet<SetupStatus>('/setup', { anonymous: true })
|
||||
.then((s) => setSetupNeeded(!s.completed))
|
||||
.catch(() => setSetupNeeded(false));
|
||||
emailRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.state.user) navigate('/', { replace: true });
|
||||
}, [auth.state.user, navigate]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await auth.login(email, password);
|
||||
navigate('/', { replace: true });
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
setError('Invalid email or password.');
|
||||
} else if (err instanceof ApiError && err.status === 429) {
|
||||
setError('Too many attempts. Wait a minute.');
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Login failed.');
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md mt-12">
|
||||
<SectionHeader prefix="Operator" highlight="Login" accent="cyan" />
|
||||
{setupNeeded && (
|
||||
<Alert accent="orange" className="mb-4">
|
||||
No admin account exists yet.{' '}
|
||||
<Link to="/setup" className="underline text-cyan">
|
||||
Run the bootstrap setup →
|
||||
</Link>
|
||||
</Alert>
|
||||
)}
|
||||
<Card accent="cyan">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<TextField
|
||||
ref={emailRef}
|
||||
label="Email"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{error && <Alert accent="red">{error}</Alert>}
|
||||
<Button type="submit" accent="cyan" disabled={busy}>
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
frontend/src/pages/ProfilePage.tsx
Normal file
137
frontend/src/pages/ProfilePage.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Alert } from '@/components/ui/Alert';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { SectionHeader } from '@/components/ui/SectionHeader';
|
||||
import { Tag } from '@/components/ui/Tag';
|
||||
import { TextField } from '@/components/ui/TextField';
|
||||
import { ApiError, apiPost } from '@/lib/api';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
export function ProfilePage() {
|
||||
const { state, logout } = useAuth();
|
||||
const user = state.user!;
|
||||
const [current, setCurrent] = useState('');
|
||||
const [next, setNext] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
|
||||
|
||||
async function handleChangePassword(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setMsg(null);
|
||||
if (next !== confirm) {
|
||||
setMsg({ kind: 'err', text: 'New passwords do not match.' });
|
||||
return;
|
||||
}
|
||||
if (next.length < 8) {
|
||||
setMsg({ kind: 'err', text: 'New password must be at least 8 characters.' });
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiPost('/auth/change-password', {
|
||||
current_password: current,
|
||||
new_password: next,
|
||||
});
|
||||
setMsg({ kind: 'ok', text: 'Password updated. You will be signed out for security.' });
|
||||
setCurrent('');
|
||||
setNext('');
|
||||
setConfirm('');
|
||||
setTimeout(() => logout(), 1500);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
const payload = err.payload as { error?: string; message?: string } | null;
|
||||
setMsg({
|
||||
kind: 'err',
|
||||
text: payload?.message ?? payload?.error ?? `HTTP ${err.status}`,
|
||||
});
|
||||
} else {
|
||||
setMsg({ kind: 'err', text: err instanceof Error ? err.message : 'Update failed.' });
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader prefix="Account" highlight="Profile" accent="cyan" />
|
||||
<div className="grid gap-4 [grid-template-columns:repeat(auto-fill,minmax(420px,1fr))]">
|
||||
<Card accent="cyan" title="Identity" sub={user.is_admin ? 'admin account' : 'operator account'}>
|
||||
<p>
|
||||
<span className="text-text-dim">email </span>
|
||||
<code className="accent-fill-cyan px-2 py-[2px] rounded-sm font-mono text-4xs">
|
||||
{user.email}
|
||||
</code>
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
<span className="text-text-dim">display </span>
|
||||
<code className="accent-fill-cyan px-2 py-[2px] rounded-sm font-mono text-4xs">
|
||||
{user.display_name ?? '—'}
|
||||
</code>
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
<span className="text-text-dim">locale </span>
|
||||
<code className="accent-fill-cyan px-2 py-[2px] rounded-sm font-mono text-4xs">
|
||||
{user.locale}
|
||||
</code>
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card accent="purple" title="Groups" sub={user.groups.length ? '' : 'no groups assigned'}>
|
||||
{user.groups.length === 0 && <p className="text-text-dim">No groups yet.</p>}
|
||||
{user.groups.map((g) => (
|
||||
<Tag key={g} accent="purple">
|
||||
{g}
|
||||
</Tag>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<Card accent="orange" title="Permissions" sub={user.permissions.length ? '' : user.is_admin ? 'admin (all)' : 'no perms'}>
|
||||
{user.is_admin && <Tag accent="orange">ADMIN — bypasses checks</Tag>}
|
||||
{user.permissions.map((p) => (
|
||||
<Tag key={p} accent="orange">
|
||||
{p}
|
||||
</Tag>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<Card accent="rose" title="Change password">
|
||||
<form onSubmit={handleChangePassword} className="space-y-3">
|
||||
<TextField
|
||||
label="Current password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="New password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={next}
|
||||
onChange={(e) => setNext(e.target.value)}
|
||||
required
|
||||
hint="Min 8 characters."
|
||||
/>
|
||||
<TextField
|
||||
label="Confirm new password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{msg && <Alert accent={msg.kind === 'ok' ? 'green' : 'red'}>{msg.text}</Alert>}
|
||||
<Button type="submit" accent="rose" disabled={busy}>
|
||||
{busy ? 'Updating…' : 'Update password'}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
163
frontend/src/pages/RegisterPage.tsx
Normal file
163
frontend/src/pages/RegisterPage.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { Alert } from '@/components/ui/Alert';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { SectionHeader } from '@/components/ui/SectionHeader';
|
||||
import { TextField } from '@/components/ui/TextField';
|
||||
import { ApiError, apiGet, apiPost } from '@/lib/api';
|
||||
|
||||
interface InvitationPreview {
|
||||
is_valid: boolean;
|
||||
reason: string | null;
|
||||
email_hint: string | null;
|
||||
expires_at: string;
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
export function RegisterPage() {
|
||||
const [params] = useSearchParams();
|
||||
const token = params.get('token') ?? '';
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [preview, setPreview] = useState<InvitationPreview | null>(null);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
const [email, setEmail] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setPreviewError('Missing invitation token in the URL.');
|
||||
return;
|
||||
}
|
||||
apiGet<InvitationPreview>(`/invitations/preview/${encodeURIComponent(token)}`, {
|
||||
anonymous: true,
|
||||
})
|
||||
.then((p) => {
|
||||
setPreview(p);
|
||||
if (p.email_hint) setEmail(p.email_hint);
|
||||
})
|
||||
.catch((err: unknown) =>
|
||||
setPreviewError(err instanceof Error ? err.message : 'Could not load invitation.'),
|
||||
);
|
||||
}, [token]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (password !== confirm) {
|
||||
setError('Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiPost(
|
||||
`/invitations/accept/${encodeURIComponent(token)}`,
|
||||
{ email, password, display_name: displayName || undefined },
|
||||
{ anonymous: true, noRefresh: true },
|
||||
);
|
||||
setDone(true);
|
||||
setTimeout(() => navigate('/login', { replace: true }), 1500);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
const payload = err.payload as { error?: string; message?: string } | null;
|
||||
setError(payload?.message ?? payload?.error ?? `HTTP ${err.status}`);
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Registration failed.');
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<div className="mx-auto max-w-md mt-12">
|
||||
<Alert accent="green">Account created. Redirecting to login…</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (previewError) {
|
||||
return (
|
||||
<div className="mx-auto max-w-md mt-12">
|
||||
<Alert accent="red">{previewError}</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!preview) {
|
||||
return (
|
||||
<div className="mx-auto max-w-md mt-12">
|
||||
<Alert accent="cyan">Loading invitation…</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!preview.is_valid) {
|
||||
return (
|
||||
<div className="mx-auto max-w-md mt-12">
|
||||
<Alert accent="red">
|
||||
This invitation is not usable: <code>{preview.reason}</code>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl mt-12">
|
||||
<SectionHeader
|
||||
prefix="Account"
|
||||
highlight="Registration"
|
||||
accent="green"
|
||||
description="Welcome — pick a password to join the platform."
|
||||
/>
|
||||
<Card accent="green">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<TextField
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
hint={preview.email_hint ? `Invited as ${preview.email_hint}` : undefined}
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Display name (optional)"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Confirm password"
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{preview.groups.length > 0 && (
|
||||
<Alert accent="cyan">
|
||||
You will be added to: {preview.groups.map((g) => `[${g}]`).join(' ')}
|
||||
</Alert>
|
||||
)}
|
||||
{error && <Alert accent="red">{error}</Alert>}
|
||||
<Button type="submit" accent="green" disabled={busy}>
|
||||
{busy ? 'Creating account…' : 'Create account'}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
frontend/src/pages/SetupPage.tsx
Normal file
144
frontend/src/pages/SetupPage.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Alert } from '@/components/ui/Alert';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { SectionHeader } from '@/components/ui/SectionHeader';
|
||||
import { TextField } from '@/components/ui/TextField';
|
||||
import { ApiError, apiGet, apiPost } from '@/lib/api';
|
||||
|
||||
interface SetupStatus {
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
export function SetupPage() {
|
||||
const navigate = useNavigate();
|
||||
const [token, setToken] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
const [completed, setCompleted] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiGet<SetupStatus>('/setup', { anonymous: true })
|
||||
.then((s) => setCompleted(s.completed))
|
||||
.catch(() => setCompleted(null));
|
||||
}, []);
|
||||
|
||||
if (completed) {
|
||||
return (
|
||||
<div className="mx-auto max-w-md mt-12">
|
||||
<SectionHeader prefix="Setup" highlight="Already Done" accent="green" />
|
||||
<Card accent="green">
|
||||
<p>An admin account already exists on this instance.</p>
|
||||
<Link to="/login" className="mt-3 inline-block underline text-cyan">
|
||||
Go to login →
|
||||
</Link>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (password !== confirm) {
|
||||
setError('Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters.');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiPost(
|
||||
'/setup',
|
||||
{
|
||||
install_token: token.trim(),
|
||||
email,
|
||||
password,
|
||||
display_name: displayName || undefined,
|
||||
},
|
||||
{ anonymous: true, noRefresh: true },
|
||||
);
|
||||
setDone(true);
|
||||
setTimeout(() => navigate('/login', { replace: true }), 1500);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
const payload = err.payload as { error?: string; message?: string } | null;
|
||||
setError(payload?.message ?? payload?.error ?? `HTTP ${err.status}`);
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Setup failed.');
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<div className="mx-auto max-w-md mt-12">
|
||||
<Alert accent="green">Admin created. Redirecting to login…</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl mt-12">
|
||||
<SectionHeader
|
||||
prefix="Bootstrap"
|
||||
highlight="Setup"
|
||||
accent="orange"
|
||||
description="One-shot — paste the install token from the api container logs to create the first admin."
|
||||
/>
|
||||
<Card accent="orange">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<TextField
|
||||
label="Install token"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
required
|
||||
hint="Found in `make logs-api` (banner: 'INSTALL TOKEN: …')."
|
||||
/>
|
||||
<TextField
|
||||
label="Admin email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Display name (optional)"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
hint="Min 8 characters."
|
||||
/>
|
||||
<TextField
|
||||
label="Confirm password"
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{error && <Alert accent="red">{error}</Alert>}
|
||||
<Button type="submit" accent="orange" disabled={busy}>
|
||||
{busy ? 'Creating admin…' : 'Create admin'}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
212
tasks/testing-m2.md
Normal file
212
tasks/testing-m2.md
Normal file
@@ -0,0 +1,212 @@
|
||||
---
|
||||
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
|
||||
```
|
||||
Reference in New Issue
Block a user