Files
sales-trainer/backend/app/api/auth_routes.py
Macky 056753e8cb feat(saas): multi-tenant isolation (Phase 1) + rate-limit/audit/org-scoped export (Phase 2)
Phase 1 (tenant isolation):
- g.org_id set on require_auth; assert_tenant()/current_org_id() choke-point helpers.
- Multi-org provisioning: POST /api/admin/users {new_org:true} (super_admin) creates a
  new org + its first admin; GET /api/admin/orgs (super_admin sees all, admin own).
- Fixed latent create_org double-id bug (dict id != store key).
- test_saas_tenant.py: org2 admin blocked from org1 group (403), can't list org1
  groups/users, sees only own org; super_admin sees all.

Phase 2 (hardening):
- Rate limit login (per-IP + per-username) + chat send (per-user) to protect LLM cost
  and slow brute force; services/rate_limit.py (in-memory + disk, no external deps).
- Audit log data/audit/audit.jsonl on org.create, user.promote_super_admin, analytics.export.
- CSV export now org-scoped (admin exports only own org).
All 8 backend suites pass.
2026-08-09 09:35:26 +07:00

93 lines
3.0 KiB
Python

"""Auth routes: login, current user, first-time admin setup. No self-registration."""
from __future__ import annotations
from flask import Blueprint, jsonify, request
from ..auth.users import AuthError
from .helpers import ApiError, current_user, require_auth
auth_bp = Blueprint("auth", __name__)
def _store():
from flask import current_app
return current_app.extensions["user_store"]
def _login_body(data: dict) -> str:
# Accept `username` (primary) or `email` (fallback), lower-cased.
return (data.get("username") or data.get("email") or "").strip().lower()
@auth_bp.post("/login")
def login():
data = request.get_json(silent=True) or {}
username = _login_body(data)
password = data.get("password") or ""
if not username or not password:
raise ApiError("username and password are required")
# Slow down brute-force / abuse: per-IP + per-username window.
from ..services.rate_limit import check as ratelimit
client_ip = request.remote_addr or "?"
if not ratelimit("login:ip", client_ip, limit=15, window=300):
raise ApiError("too many attempts, try again later", 429)
if not ratelimit("login:user", username, limit=8, window=300):
raise ApiError("too many attempts, try again later", 429)
try:
user = _store().verify(username, password)
token = _store().issue_token(user)
except AuthError as exc:
raise ApiError(str(exc), 401)
return jsonify({
"token": token,
"user": _store().public_user(user),
"must_setup": bool(user.get("must_setup")),
})
@auth_bp.get("/me")
@require_auth
def me():
return jsonify({"user": _store().public_user(current_user())})
@auth_bp.post("/setup")
@require_auth
def setup():
"""First-time admin setup: set email + change password, then clear must_setup."""
user = current_user()
data = request.get_json(silent=True) or {}
username = (data.get("username") or user.get("username") or user.get("id") or "").strip().lower()
email = (data.get("email") or "").strip()
new_password = data.get("password") or ""
if not email or not new_password:
raise ApiError("email and new password are required")
try:
updated = _store().complete_setup(username, email, new_password)
except AuthError as exc:
raise ApiError(str(exc), 400)
return jsonify({
"ok": True,
"user": _store().public_user(updated),
"must_setup": False,
})
@auth_bp.patch("/profile")
@require_auth
def profile():
"""Self-service profile update: name (and optional email). Any authenticated user."""
user = current_user()
data = request.get_json(silent=True) or {}
username = user.get("username") or user.get("id")
try:
if "name" in data:
_store().set_name(username, data.get("name"))
if "email" in data:
_store().set_email(username, data.get("email"))
except AuthError as exc:
raise ApiError(str(exc), 400)
return jsonify({"user": _store().public_user(_store().get_user(username))})