Ctrl K
ComunidadGuías…

auth.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Google OAuth login + session-based UI auth.

Flow: user hits a protected UI route → require_login decorator redirects to
/login → user clicks "Sign in with Google" → Authlib does authorization code
flow with PKCE → /oauth2/callback verifies id_token, enforces ALLOWED_EMAILS,
sets session['user']. API endpoints (api.py) keep their independent X-API-Key
auth and are CSRF-exempt at the app level.
"""
from __future__ import annotations

import logging
import os
from functools import wraps
from typing import Callable

from authlib.integrations.flask_client import OAuth
from flask import (
    Blueprint,
    Flask,
    flash,
    redirect,
    render_template,
    request,
    session,
    url_for,
)

logger = logging.getLogger(__name__)

auth_bp = Blueprint("auth", __name__)
oauth = OAuth()


def init_oauth(app: Flask) -> None:
    """Register the Google OAuth client on `app`. Call once at startup."""
    client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "").strip()
    client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "").strip()
    if not client_id or not client_secret:
        raise RuntimeError(
            "GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET must be set"
        )
    oauth.init_app(app)
    oauth.register(
        name="google",
        client_id=client_id,
        client_secret=client_secret,
        server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
        client_kwargs={
            "scope": "openid email profile",
            "code_challenge_method": "S256",  # PKCE
        },
    )


def _allowed_emails() -> frozenset[str]:
    raw = os.environ.get("ALLOWED_EMAILS", "")
    return frozenset(e.strip().lower() for e in raw.split(",") if e.strip())


def require_login(view: Callable) -> Callable:
    """Redirect to /login if no session['user']. Preserves `next` for round-trip.

    Use on UI routes only. API routes use require_api_key in api.py.
    """

    @wraps(view)
    def wrapper(*args, **kwargs):
        if "user" not in session:
            return redirect(url_for("auth.login_page", next=request.full_path.rstrip("?")))
        return view(*args, **kwargs)

    return wrapper


def _safe_next(target: str | None) -> str:
    """Only allow same-origin relative redirects after login."""
    if not target or not target.startswith("/") or target.startswith("//"):
        return url_for("index")
    return target


@auth_bp.route("/login", methods=["GET"])
def login_page():
    if "user" in session:
        return redirect(_safe_next(request.args.get("next")))
    return render_template("login.html", next=request.args.get("next", ""))


@auth_bp.route("/login/google", methods=["GET"])
def login_google():
    # Preserve where the user wanted to go before the OAuth redirect.
    session["post_login_redirect"] = _safe_next(request.args.get("next"))
    redirect_uri = url_for("auth.callback", _external=True)
    return oauth.google.authorize_redirect(redirect_uri)


@auth_bp.route("/oauth2/callback", methods=["GET"])
def callback():
    from flask import current_app

    limiter = current_app.extensions.get("limiter")
    if limiter:
        # Imperative rate limit: 10 callbacks/min per IP. Defense against
        # OAuth code replay / brute-force.
        try:
            limiter.limit("10/minute")(lambda: None)()
        except Exception:
            pass

    try:
        token = oauth.google.authorize_access_token()
    except Exception as exc:
        logger.warning("oauth_callback_failed: %s", exc)
        flash("Sign-in failed. Try again.", "error")
        return redirect(url_for("auth.login_page"))

    userinfo = token.get("userinfo") or oauth.google.parse_id_token(token, nonce=None)
    email = str(userinfo.get("email", "")).lower().strip()
    email_verified = bool(userinfo.get("email_verified"))
    name = str(userinfo.get("name") or email)

    if not email:
        logger.warning("oauth_callback_no_email")
        flash("Google did not return an email.", "error")
        return redirect(url_for("auth.login_page"))
    if not email_verified:
        logger.warning("oauth_callback_email_not_verified email=%s", email)
        flash("Email not verified by Google.", "error")
        return redirect(url_for("auth.login_page"))
    if email not in _allowed_emails():
        logger.warning("oauth_callback_email_not_allowed email=%s", email)
        flash("This email is not allowed to access Mindbaboon.", "error")
        return redirect(url_for("auth.login_page"))

    # Session fixation prevention: drop old session before setting new identity.
    target = session.pop("post_login_redirect", None) or url_for("index")
    session.clear()
    session["user"] = {"email": email, "name": name}
    session.permanent = True
    logger.info("login_success email=%s", email)
    return redirect(_safe_next(target))


@auth_bp.route("/logout", methods=["POST"])
def logout():
    email = (session.get("user") or {}).get("email", "?")
    session.clear()
    logger.info("logout email=%s", email)
    return redirect(url_for("auth.login_page"))