Files
microfish/backend/app/api/auth.py
Kunthawat Greethong 8b84378fe1 feat: SaaS foundation for CrowdSight
Elevate MiroFish/CrowdSight from single-container dev to a SaaS foundation:

- Local memory backend (Zep-compatible): memory services/models, local graph
  builder + updater, AgentActivity seam, import-boundary isolation; Zep stays
  default, local is opt-in behind MEMORY_BACKEND. Semantic parity not yet proven.
- Durable product persistence: projects/simulations/reports schema (migration
  0007) + tenant/owner-scoped ProductRepository + dual-write + scoped_project
  read-first + ArtifactStore abstraction; durable JobQueue + worker.py.
- SaaS hardening: durable RateLimiter (wired to login), UsageService (LLM
  accounting), redacted AuditService, idempotency, CORS allowlist, safe API
  errors, single-use PasswordResetService + endpoints (covers invite-pending).
- Exactly 3 roles (super_admin/admin/user) with tenant authz policy.
- Admin UI: GET/POST/PATCH /api/admin/users + GET/PUT /api/admin/settings
  (super-admin only, encrypted/masked); AdminView.vue + SettingsView.vue with
  admin/super-admin route guards, th/en i18n.
- Production deploy topology: multi-stage Dockerfile (frontend build + gunicorn
  wsgi + nginx SPA-proxy + supervisord worker), backend/wsgi.py, gunicorn dep.

Backend 197 passed; frontend 10 tests + build green. ruff unavailable (gap).
No commit of credentials; secrets handled via env/.env.example.
Deferred: Zep semantic A/B parity, object storage cutover, mobile QA, EasyPanel
container build of deploy topology.
2026-08-31 13:05:21 +07:00

265 lines
8.8 KiB
Python

"""Authentication endpoints for the first SaaS foundation slice."""
from __future__ import annotations
from datetime import datetime, timezone
from flask import Blueprint, current_app, g, jsonify, request
from ..security.auth import issue_csrf_token, require_auth
from ..services.identity import IdentityRepository, PasswordService, SessionService
from ..utils.api_errors import ApiError
from ..utils.locale import t
auth_bp = Blueprint("auth", __name__)
SESSION_COOKIE = "crowdsight_session"
@auth_bp.errorhandler(ApiError)
def handle_auth_error(error: ApiError):
return jsonify(error.to_payload(t)), error.status_code
def _session_factory():
factory = current_app.extensions.get("crowdsight_session_factory")
if factory is None:
raise ApiError("auth_unavailable", 503, "api.internalError")
return factory
# Login attempts allowed per 15-minute window per email key.
_LOGIN_LIMIT = 5
_LOGIN_WINDOW_MINUTES = 15
def _enforce_login_rate_limit(email: str) -> None:
"""Reject excessive login attempts for an email (brute-force protection)."""
from datetime import timedelta
from ..services.rate_limiter import RateLimiter
normalized = (email or "").strip().casefold()
if not normalized:
return
factory = _session_factory()
with factory() as session:
limiter = RateLimiter(
session, window=timedelta(minutes=_LOGIN_WINDOW_MINUTES), limit=_LOGIN_LIMIT
)
allowed = limiter.check_and_record("login", key=normalized)
if not allowed:
raise ApiError("too_many_attempts", 429, "api.tooManyAttempts")
def _record_audit(
*, organization_id, actor_user_id=None, action, target_type, target_id=None, details=None
):
"""Best-effort audit record; never raises on audit-store failure."""
try:
from ..services.audit_service import AuditService
factory = _session_factory()
with factory() as session:
AuditService(session).record(
organization_id=organization_id,
actor_user_id=actor_user_id,
action=action,
target_type=target_type,
target_id=target_id,
details=details,
)
session.commit()
except Exception:
pass
def _serialize_identity(context):
return {
"user": {
"id": context.user.id,
"email": context.user.email_normalized,
"locale": context.user.locale,
},
"organization": {
"id": context.organization.id,
"name": context.organization.name,
"slug": context.organization.slug,
},
"role": context.membership.role.value,
}
def _select_membership(repo, user, organization_slug: str | None):
memberships = repo.list_active_memberships(user.id)
if organization_slug:
normalized_slug = organization_slug.strip().casefold()
for membership, organization in memberships:
if organization.slug == normalized_slug:
return membership, organization
raise ApiError("organization_not_found", 404, "api.organizationNotFound")
if len(memberships) != 1:
raise ApiError("organization_required", 400, "api.organizationRequired")
return memberships[0]
@auth_bp.post("/login")
def login():
data = request.get_json(silent=True) or {}
if not isinstance(data, dict):
raise ApiError("invalid_request", 400, "api.requestError")
email = data.get("email")
password = data.get("password")
if not isinstance(email, str) or not isinstance(password, str):
raise ApiError("invalid_credentials", 401, "api.invalidCredentials")
_enforce_login_rate_limit(email)
factory = _session_factory()
with factory() as session:
repo = IdentityRepository(session)
try:
user = repo.get_user_by_email(email)
except ValueError:
user = None
if user is None or user.status != "active":
raise ApiError("invalid_credentials", 401, "api.invalidCredentials")
if not PasswordService.verify_password(user.password_hash, password):
raise ApiError("invalid_credentials", 401, "api.invalidCredentials")
membership, organization = _select_membership(
repo, user, data.get("organization_slug")
)
raw_token, _stored = SessionService.create(session, user, membership.id)
user.last_login_at = datetime.now(timezone.utc)
session.commit()
_record_audit(
organization_id=organization.id,
actor_user_id=user.id,
action="auth.login",
target_type="user",
target_id=user.id,
details={"method": "password"},
)
with factory() as session:
context = type(
"LoginContext",
(),
{"user": user, "membership": membership, "organization": organization},
)()
response = jsonify({"success": True, "data": _serialize_identity(context)})
response.set_cookie(
SESSION_COOKIE,
raw_token,
max_age=SessionService.DEFAULT_TTL_SECONDS,
httponly=True,
secure=bool(current_app.config.get("SESSION_COOKIE_SECURE", False)),
samesite="Lax",
)
response.set_cookie(
"crowdsight_csrf",
issue_csrf_token(),
max_age=SessionService.DEFAULT_TTL_SECONDS,
httponly=False,
secure=bool(current_app.config.get("SESSION_COOKIE_SECURE", False)),
samesite="Lax",
)
return response
@auth_bp.get("/me")
def me():
raw_token = request.cookies.get(SESSION_COOKIE)
factory = _session_factory()
with factory() as session:
context = SessionService.resolve(session, raw_token or "")
if context is None:
raise ApiError("unauthorized", 401, "common.unauthorized")
session.commit()
return jsonify({"success": True, "data": _serialize_identity(context)})
@auth_bp.post("/logout")
@require_auth
def logout():
raw_token = request.cookies.get(SESSION_COOKIE, "")
SessionService.revoke(g.db_session, raw_token)
response = jsonify({"success": True, "data": {"logged_out": True}})
response.delete_cookie(SESSION_COOKIE)
response.delete_cookie("crowdsight_csrf")
return response
@auth_bp.post("/password-reset/request")
def password_reset_request():
"""Request a password reset for an email (always returns success)."""
from datetime import timedelta
from ..services.password_reset import DEFAULT_TTL, PasswordResetService
data = request.get_json(silent=True) or {}
email = data.get("email")
if not isinstance(email, str) or not email.strip():
raise ApiError("invalid_email", 400, "api.invalidEmail")
factory = _session_factory()
with factory() as session:
repo = IdentityRepository(session)
user = repo.get_user_by_email(email)
if user is not None:
svc = PasswordResetService(session, ttl=DEFAULT_TTL)
_token = svc.create_token(user_id=user.id)
session.commit()
# Enrolment-agnostic response avoids account-enumeration.
return jsonify({"success": True, "data": {"sent": True}})
@auth_bp.post("/password-reset/confirm")
def password_reset_confirm():
"""Set a new password using a valid reset token."""
from ..services.identity import PasswordService
from ..services.password_reset import PasswordResetService
data = request.get_json(silent=True) or {}
token = data.get("token")
email = data.get("email")
new_password = data.get("password")
if not isinstance(token, str) or not token:
raise ApiError("invalid_token", 400, "api.invalidRequest")
if not isinstance(email, str) or not isinstance(new_password, str):
raise ApiError("invalid_credentials", 401, "api.invalidCredentials")
factory = _session_factory()
org_id = None
with factory() as session:
repo = IdentityRepository(session)
user = repo.get_user_by_email(email)
if user is None:
raise ApiError("invalid_credentials", 401, "api.invalidCredentials")
new_hash = PasswordService.hash_password(new_password)
svc = PasswordResetService(session)
ok = svc.consume_token(token, user_id=user.id)
if not ok:
raise ApiError("invalid_token", 400, "api.invalidRequest")
user.password_hash = new_hash
session.flush()
membership = (
repo.list_active_memberships(user.id)[0]
if repo.list_active_memberships(user.id)
else None
)
if membership is not None:
org_id = membership[1].id
session.commit()
_record_audit(
organization_id=org_id,
actor_user_id=user.id,
action="auth.password_reset",
target_type="user",
)
return jsonify({"success": True, "data": {"reset": True}})