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:
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
101
backend/src/mimic/api/auth.py
Normal file
101
backend/src/mimic/api/auth.py
Normal 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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
32
backend/src/mimic/schemas/auth.py
Normal file
32
backend/src/mimic/schemas/auth.py
Normal 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]
|
||||
Reference in New Issue
Block a user