"""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}})