From 38b35c933a89de29b2790ea039b21c097e020217 Mon Sep 17 00:00:00 2001 From: knacky Date: Sat, 23 May 2026 04:21:44 +0200 Subject: [PATCH] feat(backend): wire auth endpoints + dev CORS (sprint 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- backend/pyproject.toml | 2 + backend/src/mimic/api/__init__.py | 2 + backend/src/mimic/api/_helpers.py | 19 +++++ backend/src/mimic/api/auth.py | 101 ++++++++++++++++++++++++++ backend/src/mimic/app.py | 31 ++++++++ backend/src/mimic/auth/identity.py | 44 +++++++---- backend/src/mimic/schemas/__init__.py | 3 + backend/src/mimic/schemas/auth.py | 32 ++++++++ 8 files changed, 220 insertions(+), 14 deletions(-) create mode 100644 backend/src/mimic/api/auth.py create mode 100644 backend/src/mimic/schemas/auth.py diff --git a/backend/pyproject.toml b/backend/pyproject.toml index d7f59c1..72534c1 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -13,6 +13,7 @@ authors = [{ name = "RT" }] dependencies = [ "flask>=3.0,<4.0", + "flask-cors>=4.0,<6.0", "flask-socketio>=5.3,<6.0", "flask-login>=0.6.3,<1.0", "flask-migrate>=4.0,<5.0", @@ -115,6 +116,7 @@ module = [ "flask_socketio.*", "flask_migrate.*", "flask_login.*", + "flask_cors.*", "pythonjsonlogger.*", "gevent.*", "testcontainers.*", diff --git a/backend/src/mimic/api/__init__.py b/backend/src/mimic/api/__init__.py index be1f66d..0359546 100644 --- a/backend/src/mimic/api/__init__.py +++ b/backend/src/mimic/api/__init__.py @@ -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") diff --git a/backend/src/mimic/api/_helpers.py b/backend/src/mimic/api/_helpers.py index 96750ee..1e9c34b 100644 --- a/backend/src/mimic/api/_helpers.py +++ b/backend/src/mimic/api/_helpers.py @@ -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: , message: }`.""" + return jsonify({"error": code, "message": message}), status + + def audit_write( *, action: str, diff --git a/backend/src/mimic/api/auth.py b/backend/src/mimic/api/auth.py new file mode 100644 index 0000000..6d56a79 --- /dev/null +++ b/backend/src/mimic/api/auth.py @@ -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 diff --git a/backend/src/mimic/app.py b/backend/src/mimic/app.py index 86a2738..a518824 100644 --- a/backend/src/mimic/app.py +++ b/backend/src/mimic/app.py @@ -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, + ) diff --git a/backend/src/mimic/auth/identity.py b/backend/src/mimic/auth/identity.py index 10912dc..3f0fd00 100644 --- a/backend/src/mimic/auth/identity.py +++ b/backend/src/mimic/auth/identity.py @@ -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) diff --git a/backend/src/mimic/schemas/__init__.py b/backend/src/mimic/schemas/__init__.py index 7e84f79..3f6d395 100644 --- a/backend/src/mimic/schemas/__init__.py +++ b/backend/src/mimic/schemas/__init__.py @@ -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", diff --git a/backend/src/mimic/schemas/auth.py b/backend/src/mimic/schemas/auth.py new file mode 100644 index 0000000..9837c29 --- /dev/null +++ b/backend/src/mimic/schemas/auth.py @@ -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]