feat(m2): auth, JWT, invitations, bootstrap, RTOps SPA pages

Crypto + tokens
- app/core/security.py: Argon2id PasswordHasher (time_cost=2, memory_cost=
  64 MiB, parallelism=2) + opaque-token SHA-256 helpers (raw token shown
  once, only the hash lives in the DB).
- app/core/jwt_tokens.py: HS256, claims iss/sub/type/jti/iat/exp. Access
  1h, refresh 30d.

Services
- services/auth.py: login, refresh with token rotation + reuse-detection
  chain revoke, logout (idempotent), change_password (forces logout-all).
- services/invitations.py: create, preview, accept, revoke. Default 7d TTL.
- services/bootstrap.py: seeds the 3 system groups (admin/redteam/blueteam),
  consumes the install token, attaches the first user to admin.
- core/install_token.py: mints, persists in settings, marks consumed,
  regenerate hook for /diag/reset.

API
- POST /setup (consume install token, create 1st admin) + GET /setup
  (status).
- POST /auth/{login,refresh,logout,change-password} + GET /auth/me.
- POST /invitations + GET /invitations + GET /invitations/preview/<token> +
  POST /invitations/accept/<token> + POST /invitations/<id>/revoke.
- POST /diag/reset: test-only kill switch (truncate auth tables + mint
  fresh install token). Allowed in dev too (with WARNING log) so the e2e
  suite can run against a make-up stack; production locked out.

Middleware
- @require_auth populates g.current_user (snapshot dataclass, session
  closed before request handler runs).
- @require_perm(*codes): atomic perm union check; admin group bypasses.
  Perm catalogue lands in M3, scaffolding here.
- flask-limiter: 10/min/IP on /auth/login & /auth/refresh, 5/min on
  /auth/change-password & /setup, 10–20/min on invitation endpoints.
  Disabled in APP_ENV=test.

CLI
- flask --app app.cli metamorph print-install-token [--force]
- flask --app app.cli metamorph seed-mitre (M4 placeholder)

Refresh cookie metamorph_refresh: HttpOnly + Secure (localhost is a secure
context for modern browsers) + SameSite=Strict + Path=/api/v1/auth/.

Email validation: app.api._validation.Email permissive RFC-shape regex so
internal TLDs (.local/.corp/.test) are accepted — pydantic.EmailStr's
deliverability check is too strict for red-team labs.

Frontend
- lib/{api,auth}.ts: access token in module memory, refresh cookie,
  automatic 401-retry via /auth/refresh, useAuth() hook.
- components/{Layout,RequireAuth}.tsx + ui/{TextField,Alert}.tsx.
- pages/{Login,Setup,Register,Profile}.

Testing
- tests/test_auth_flow.py: 15 integration tests (24 backend total).
- e2e/tests/m2-auth.spec.ts: 8 Playwright tests (20 e2e total).
- tasks/testing-m2.md.

DoD: make test-api → 24 passed, make e2e → 20 passed; spec-reviewer pass
applied (Secure unconditional, refresh limit 10/min/IP).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-11 06:16:48 +02:00
parent e995853f0d
commit 700b563297
27 changed files with 3123 additions and 0 deletions

View File

View 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",
]

View 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)

View 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