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.
265 lines
8.8 KiB
Python
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}})
|