feat(backend): wire auth endpoints + dev CORS (sprint 1)

Three login endpoints under /api/v1/auth/ + dev-only CORS so the Vite
frontend can drive the session cookie.

- POST /login validates local credentials and sets a Flask session cookie.
  Returns the CurrentUser shape on 200 (user_id, username=email,
  display_name, role, permissions, groups). Uniform 401 invalid_credentials
  on bad password or unknown user; a bcrypt round against a dummy hash runs
  even on unknown users so the request timing does not enumerate accounts.
  Audits an auth.login row and bumps user.last_login_at.
- POST /logout (login_required) clears the session, returns 204, audits an
  auth.logout row.
- GET /me returns the current principal or 401 not_authenticated. Used by
  the frontend at boot to rehydrate state.

Side wiring:
- LoginManager.unauthorized_handler emits the same {error, message} JSON
  envelope so @login_required 401s match the rest of the API surface.
- api/_helpers gains `serialize_current_user(AuthUser) -> CurrentUser` and
  `api_error(code, message, status)` — used by the auth blueprint and
  available to follow-up endpoints.
- AuthUser carries display_name + user_type now; identity.load_user routes
  through a new `authuser_from_orm()` helper that the login endpoint also
  uses so /login and the user_loader produce identical shapes.
- Dev-only CORS via flask-cors on /api/*, gated on
  MIMIC_ENV=development AND MIMIC_CORS_ORIGINS non-empty. Prod keeps
  same-origin (reverse proxy fronts the SPA + API).
- LoginRequest + CurrentUser DTOs added to mimic.schemas.

No frontend-visible change to engagements (sprint-0 already shipped
created_by_id, audit log, F11 scope).
This commit is contained in:
knacky
2026-05-23 04:21:44 +02:00
parent a8c5400f97
commit 38b35c933a
8 changed files with 220 additions and 14 deletions

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from flask import Flask
from mimic.api.auth import bp as auth_bp
from mimic.api.engagements import bp as engagements_bp
from mimic.api.hosts import bp as hosts_bp
from mimic.api.scenarios import bp as scenarios_bp
@@ -11,6 +12,7 @@ from mimic.api.ttps import bp as ttps_bp
def register_blueprints(app: Flask) -> None:
app.register_blueprint(auth_bp, url_prefix="/api/v1/auth")
app.register_blueprint(engagements_bp, url_prefix="/api/v1/engagements")
app.register_blueprint(hosts_bp, url_prefix="/api/v1")
app.register_blueprint(ttps_bp, url_prefix="/api/v1/library/ttps")

View File

@@ -11,8 +11,10 @@ from pydantic import BaseModel, ValidationError
from sqlalchemy.orm import Session
from mimic.audit import AuditWriter
from mimic.auth.identity import AuthUser
from mimic.extensions import db
from mimic.rbac.matrix import GroupName
from mimic.schemas import CurrentUser
def parse_body[T: BaseModel](model: type[T]) -> T:
@@ -50,6 +52,23 @@ def is_rt_lead() -> bool:
return GroupName.RT_LEAD.value in groups
def serialize_current_user(user: AuthUser) -> CurrentUser:
"""Build the API response describing the authenticated principal."""
return CurrentUser(
user_id=user.id,
username=user.email,
display_name=user.display_name,
role=user.user_type,
permissions=sorted(p.value for p in user.permissions),
groups=sorted(user.groups),
)
def api_error(code: str, message: str, status: int) -> tuple[Response, int]:
"""Uniform error envelope: `{error: <code>, message: <human>}`."""
return jsonify({"error": code, "message": message}), status
def audit_write(
*,
action: str,

View File

@@ -0,0 +1,101 @@
"""Authentication endpoints (local password v1, sprint 1)."""
from __future__ import annotations
from datetime import UTC, datetime
from flask import Blueprint, session
from flask.typing import ResponseReturnValue
from flask_login import current_user, login_required, login_user, logout_user
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from mimic.api._helpers import (
api_error,
audit_write,
jsonify_model,
parse_body,
serialize_current_user,
)
from mimic.auth.identity import AuthUser, authuser_from_orm
from mimic.auth.password import check_password
from mimic.db.models import User, UserGroup
from mimic.extensions import db
from mimic.schemas import LoginRequest
bp = Blueprint("auth", __name__)
@bp.post("/login")
def login() -> ResponseReturnValue:
"""Validate local credentials and start a Flask session.
Uniform 401 on bad credentials (no leak between "unknown user" and
"wrong password") and on disabled accounts.
"""
payload = parse_body(LoginRequest)
stmt = (
select(User)
.where(User.email == payload.username)
.options(selectinload(User.group_links).selectinload(UserGroup.group))
)
user = db.session.execute(stmt).scalar_one_or_none()
if user is None or not user.is_active:
# Run a bcrypt round anyway to flatten the timing signal between
# "unknown user" and "wrong password".
check_password(payload.password, "$2b$12$" + "x" * 53)
return api_error("invalid_credentials", "invalid username or password", 401)
if not check_password(payload.password, user.local_password_hash):
return api_error("invalid_credentials", "invalid username or password", 401)
user.last_login_at = datetime.now(tz=UTC)
db.session.commit()
auth_user = authuser_from_orm(user)
login_user(auth_user)
session.permanent = True
audit_write(
action="auth.login",
resource_type="user",
resource_id=user.id,
metadata={"username": user.email},
)
return jsonify_model(serialize_current_user(auth_user))
@bp.post("/logout")
@login_required # type: ignore[untyped-decorator]
def logout() -> ResponseReturnValue:
"""Clear the Flask session."""
user_id = getattr(current_user, "id", None)
logout_user()
if user_id is not None:
audit_write(
action="auth.logout",
resource_type="user",
resource_id=user_id,
)
return "", 204
@bp.get("/me")
def me() -> ResponseReturnValue:
"""Return the current principal, or 401 if anonymous.
Frontend calls this at boot to rehydrate the session.
"""
if not getattr(current_user, "is_authenticated", False):
return api_error("not_authenticated", "no active session", 401)
return jsonify_model(serialize_current_user(_as_authuser(current_user)))
def _as_authuser(principal: object) -> AuthUser:
"""Narrow Flask-Login's `current_user` proxy back to `AuthUser`."""
if not isinstance(principal, AuthUser):
raise TypeError("current_user is not an AuthUser")
return principal

View File

@@ -35,12 +35,21 @@ def create_app(settings: Settings | None = None) -> Flask:
login_manager.init_app(app)
login_manager.user_loader(load_user)
@login_manager.unauthorized_handler # type: ignore[untyped-decorator]
def _unauthorized() -> ResponseReturnValue:
# API returns JSON; never redirect to a login page.
return (
jsonify({"error": "not_authenticated", "message": "no active session"}),
401,
)
socketio.init_app(
app,
cors_allowed_origins=settings.cors_origins or "*",
async_mode="gevent",
)
_enable_cors_in_dev(app, settings)
register_blueprints(app)
@app.get("/healthz")
@@ -48,3 +57,25 @@ def create_app(settings: Settings | None = None) -> Flask:
return jsonify(status="ok"), 200
return app
def _enable_cors_in_dev(app: Flask, settings: Settings) -> None:
"""Dev-only CORS for the Vite frontend on http://localhost:5173.
In production, the reverse proxy (Caddy + same-origin) terminates this
concern; enabling CORS there would expand the CSRF surface for no benefit.
"""
if settings.env != "development":
return
if not settings.cors_origins:
return
from flask_cors import CORS # noqa: PLC0415 — keeps the prod import path lean
CORS(
app,
resources={r"/api/*": {"origins": settings.cors_origins}},
supports_credentials=True,
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "X-Requested-With"],
max_age=600,
)

View File

@@ -9,6 +9,7 @@ from sqlalchemy import select
from sqlalchemy.orm import selectinload
from mimic.db.models import User, UserGroup
from mimic.db.types import UserType
from mimic.extensions import db
from mimic.rbac.matrix import GROUP_PERMISSIONS, GroupName, Permission
@@ -19,6 +20,8 @@ class AuthUser:
id: UUID
email: str
display_name: str | None = None
user_type: UserType = UserType.RT_OPERATOR
permissions: frozenset[Permission] = field(default_factory=frozenset)
groups: frozenset[str] = field(default_factory=frozenset)
is_authenticated: bool = True
@@ -29,6 +32,32 @@ class AuthUser:
return str(self.id)
def _resolve_permissions(group_names: set[str]) -> set[Permission]:
perms: set[Permission] = set()
for group_name in group_names:
try:
perms.update(GROUP_PERMISSIONS[GroupName(group_name)])
except ValueError:
continue
return perms
def authuser_from_orm(user: User) -> AuthUser:
"""Build an `AuthUser` from a refreshed `User` ORM row.
Caller must ensure `user.group_links` is loaded (selectinload or eager).
"""
group_names = {link.group.name for link in user.group_links}
return AuthUser(
id=user.id,
email=user.email,
display_name=user.display_name,
user_type=user.type,
permissions=frozenset(_resolve_permissions(group_names)),
groups=frozenset(group_names),
)
def load_user(user_id: str) -> AuthUser | None:
"""Flask-Login `user_loader` callback."""
try:
@@ -44,17 +73,4 @@ def load_user(user_id: str) -> AuthUser | None:
user = db.session.execute(stmt).scalar_one_or_none()
if user is None or not user.is_active:
return None
group_names = {link.group.name for link in user.group_links}
perms: set[Permission] = set()
for group_name in group_names:
try:
perms.update(GROUP_PERMISSIONS[GroupName(group_name)])
except ValueError:
continue
return AuthUser(
id=user.id,
email=user.email,
permissions=frozenset(perms),
groups=frozenset(group_names),
)
return authuser_from_orm(user)

View File

@@ -1,5 +1,6 @@
"""Pydantic 2 request/response DTOs."""
from mimic.schemas.auth import CurrentUser, LoginRequest
from mimic.schemas.engagement import (
EngagementCreate,
EngagementRead,
@@ -16,12 +17,14 @@ from mimic.schemas.scenario import (
from mimic.schemas.ttp import TtpCreate, TtpRead, TtpUpdate
__all__ = [
"CurrentUser",
"EngagementCreate",
"EngagementRead",
"EngagementUpdate",
"HostCreate",
"HostRead",
"HostUpdate",
"LoginRequest",
"ScenarioCreate",
"ScenarioRead",
"ScenarioStepCreate",

View File

@@ -0,0 +1,32 @@
"""Auth DTOs (login / logout / me)."""
from __future__ import annotations
from uuid import UUID
from pydantic import BaseModel, Field
from mimic.db.types import UserType
class LoginRequest(BaseModel):
"""Credentials posted to `/api/v1/auth/login`.
`username` is mapped to the `user.email` column server-side; the frontend
label remains generic so future identity sources (e.g. Keycloak `preferred_
username`) can route through the same endpoint.
"""
username: str = Field(min_length=1, max_length=255)
password: str = Field(min_length=1, max_length=512)
class CurrentUser(BaseModel):
"""Response shape for `/login`, `/me`."""
user_id: UUID
username: str
display_name: str | None
role: UserType
permissions: list[str]
groups: list[str]