Files
sales-trainer/backend/app/api/auth_routes.py

166 lines
6.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, SetupAlreadyCompletedError
from .helpers import ApiError, current_user, request_json_object, 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.
ident = data.get("username")
if ident is None:
ident = data.get("email")
if not isinstance(ident, str):
return ""
return ident.strip().lower()
@auth_bp.post("/login")
def login():
data = request_json_object()
username = _login_body(data)
password = data.get("password")
if not username or not isinstance(password, str) 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:
raise ApiError("invalid credentials", 401)
return jsonify({
"token": token,
"user": _store().public_user(user),
"must_setup": user.get("must_setup") is True,
})
@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_json_object()
raw_username = user.get("username") or user.get("id")
if not isinstance(raw_username, str):
raise ApiError("invalid account state", 401)
username = raw_username.strip().lower()
requested_username_raw = data.get("username")
if requested_username_raw is not None and not isinstance(requested_username_raw, str):
raise ApiError("permission denied", 403)
requested_username = (requested_username_raw or "").strip().lower()
if requested_username and requested_username != username:
raise ApiError("permission denied", 403)
if user.get("must_setup") is not True:
raise ApiError("setup already completed", 409)
email = data.get("email")
new_password = data.get("password")
# SaaS: consent to Terms/Privacy is required before use.
if data.get("accepted_terms") is not True:
raise ApiError("you must accept the Terms of Service and Privacy Policy to continue", 400)
if email is None or email == "" or new_password is None or new_password == "":
raise ApiError("email and new password are required")
try:
updated = _store().complete_setup(
username,
email,
new_password,
accepted_terms=data.get("accepted_terms"),
accepted_terms_at=__import__("time").strftime("%Y-%m-%dT%H:%M:%SZ"),
)
except SetupAlreadyCompletedError:
raise ApiError("setup already completed", 409)
except AuthError:
raise ApiError("setup could not be completed", 400)
return jsonify({
"ok": True,
"token": _store().issue_token(updated),
"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_json_object()
username = user.get("username") or user.get("id")
updates: dict[str, object] = {}
if "name" in data:
updates["name"] = data.get("name")
if "email" in data:
updates["email"] = data.get("email")
try:
updated = _store().update_user_fields(username, **updates)
except AuthError:
raise ApiError("profile update failed", 400)
return jsonify({"user": _store().public_user(updated)})
@auth_bp.post("/password")
@require_auth
def change_password():
"""Change the authenticated user's password after current-password verification."""
data = request_json_object()
if "username" in data:
# The target is always the authenticated subject; never accept a caller-
# supplied username as an alternate mutation target.
raise ApiError("permission denied", 403)
current_password = data.get("current_password")
new_password = data.get("new_password")
if not isinstance(current_password, str) or not isinstance(new_password, str):
raise ApiError("current and new password are required", 400)
from ..services.rate_limit import check as ratelimit
user = current_user()
username = user.get("username") or user.get("id")
client_ip = request.remote_addr or "?"
if not isinstance(username, str) or not username:
raise ApiError("invalid account state", 401)
if not ratelimit("password-change:user", username, limit=5, window=300):
raise ApiError("too many attempts, try again later", 429)
if not ratelimit("password-change:ip", client_ip, limit=20, window=300):
raise ApiError("too many attempts, try again later", 429)
try:
updated = _store().change_password(
username,
current_password,
new_password,
)
except AuthError as exc:
if str(exc) == "current password is invalid":
raise ApiError("current password is incorrect", 401)
raise ApiError("password change failed", 400)
return jsonify({
"ok": True,
"token": _store().issue_token(updated),
"user": _store().public_user(updated),
})