agilentics / boiler
"""Sign in, sign out, and which organisation the session is looking at.

The worked example of a façade: each endpoint reads the request, calls one core
function, and serialises the result. No rule about who may do what is decided
here - that lives in app/core.
"""
import logging

from flask import jsonify, redirect, render_template, request
from flask import session as flask_session

from app.core import auth as auth_core
from app.core import orgs as orgs_core
from db.db_declarations import User
from utils.db_wrapper import db_session

from .crud import ORG_KEY, USER_KEY, endpoint, principal

logger = logging.getLogger(__name__)


def _begin_session(user_id: int, org_id: int | None) -> None:
    """Start an authenticated session. Every sign-in path funnels through here.

    `clear()` before anything is written: a pre-existing session id must not
    survive a successful sign-in, or an attacker who fixed the cookie beforehand
    holds a session that is now authenticated as the victim. It also drops the
    CSRF token, so the next rendered page mints a fresh one.
    """
    flask_session.clear()
    flask_session[USER_KEY] = user_id
    flask_session[ORG_KEY] = org_id
    flask_session.permanent = True


def page_login():
    """The sign-in page. Already signed in, it is not a form but a redirect -
    showing a login form to someone who is logged in invites them to believe
    their session ended."""
    if flask_session.get(USER_KEY):
        return redirect("/")
    return render_template("login.html")


@endpoint
def api_login():
    """Exchange an email and password for a session."""
    body = request.get_json(silent=True) or {}
    user = auth_core.authenticate_password(
        db_session, body.get("email", ""), body.get("password", ""))

    # Resolve the principal before starting the session, so an account with no
    # membership is refused rather than left holding a session it cannot use.
    p = auth_core.principal_for_user(db_session, user)

    _begin_session(user.id, p.org_id)

    logger.info("Signed in %s into org %s.", user.email, p.org_id)
    return jsonify({"user": user.to_dict(), "org_id": p.org_id})


def page_logout():
    flask_session.clear()
    return redirect("/login")


@endpoint
def api_me():
    """Who the current session is, and which organisations it may enter - what
    the account menu and the org switcher render from."""
    p = principal()
    user = db_session.get(User, p.user_id)
    return jsonify({
        "user": user.to_dict(),
        "org_id": p.org_id,
        "scopes": sorted(p.scopes),
        "orgs": [o.to_dict() for o in orgs_core.orgs_for_user(db_session, p.user_id)],
    })


@endpoint
def api_org_select():
    """Point the session at another of this account's organisations.

    Validated through orgs_for_user rather than trusted from the body: the org id
    in the session is what every later query is scoped by, so accepting one the
    caller does not belong to would hand them another tenant's data for the rest
    of the session.
    """
    p = principal()
    body = request.get_json(silent=True) or {}
    org_id = body.get("org_id")

    if not any(o.id == org_id for o in orgs_core.orgs_for_user(db_session, p.user_id)):
        # NotFound, not Forbidden - see orgs.get_org.
        return jsonify({"error": "No such organisation."}), 404

    flask_session[ORG_KEY] = org_id
    return jsonify({"org_id": org_id})


@endpoint
def api_password_change():
    p = principal()
    body = request.get_json(silent=True) or {}
    auth_core.change_password(
        db_session, p, body.get("current", ""), body.get("new", ""))
    return jsonify({"ok": True})