import asyncio import base64 import hashlib import os import secrets from quart import Blueprint, g, jsonify, redirect, request, session from scribe.auth import login_required, get_current_user_id from scribe.config import Config from scribe.rate_limit import is_rate_limited from scribe.services.auth import ( authenticate, change_password, create_password_reset_token, create_user, get_user_by_email, get_user_by_id, get_user_by_username, get_user_count, invalidate_other_sessions, is_registration_open, register_with_invitation, reset_password_with_token, update_user_email, validate_invitation_token, verify_password, ) from scribe.services.logging import log_audit from scribe.services.notifications import ( notify_security_event, send_password_reset_email, send_password_reset_success_email, ) from scribe.services.email import get_base_url, is_smtp_configured from scribe.services.oauth import ( build_auth_url, exchange_code, get_userinfo, find_or_create_oauth_user, ) auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth") def _client_ip() -> str: if Config.TRUST_PROXY_HEADERS: for header in ("X-Forwarded-For", "X-Real-IP"): val = request.headers.get(header, "").split(",")[0].strip() if val: return val return request.remote_addr or "" @auth_bp.route("/register", methods=["POST"]) async def register(): if not Config.LOCAL_AUTH_ENABLED: return jsonify({"error": "Local authentication is disabled. Please use SSO."}), 403 if await is_rate_limited(f"register:{_client_ip()}", max_requests=5, window_seconds=300): return jsonify({"error": "Too many registration attempts. Try again later."}), 429 if not await is_registration_open(): return jsonify({"error": "Registration is closed"}), 403 data = await request.get_json() username = (data.get("username") or "").strip() password = data.get("password") or "" email = (data.get("email") or "").strip() or None if not username: return jsonify({"error": "Username is required"}), 400 if len(password) < 8: return jsonify({"error": "Password must be at least 8 characters"}), 400 try: user = await create_user(username, password, email) except Exception: return jsonify({"error": "Username already taken"}), 409 session["user_id"] = user.id session["session_version"] = user.session_version await log_audit("register", user_id=user.id, username=user.username, ip_address=_client_ip()) return jsonify(user.to_dict()), 201 @auth_bp.route("/login", methods=["POST"]) async def login(): if not Config.LOCAL_AUTH_ENABLED: return jsonify({"error": "Local authentication is disabled. Please use SSO."}), 403 if await is_rate_limited(f"login:{_client_ip()}", max_requests=10, window_seconds=60): return jsonify({"error": "Too many login attempts. Try again later."}), 429 data = await request.get_json() username = (data.get("username") or "").strip() password = data.get("password") or "" if not username or not password: return jsonify({"error": "Username and password are required"}), 400 user = await authenticate(username, password) if not user: await log_audit("login_failed", username=username, ip_address=_client_ip()) # Try to notify the actual user about the failed attempt target_user = await get_user_by_username(username) if target_user: asyncio.create_task(notify_security_event( target_user.id, "login_failed", {"ip_address": _client_ip(), "username": username}, )) return jsonify({"error": "Invalid username or password"}), 401 session["user_id"] = user.id session["session_version"] = user.session_version await log_audit("login", user_id=user.id, username=user.username, ip_address=_client_ip()) asyncio.create_task(notify_security_event( user.id, "login", {"ip_address": _client_ip()}, )) return jsonify(user.to_dict()) @auth_bp.route("/logout", methods=["POST"]) async def logout(): uid = session.get("user_id") if uid: user = await get_user_by_id(uid) await log_audit("logout", user_id=uid, username=user.username if user else None, ip_address=_client_ip()) asyncio.create_task(notify_security_event( uid, "logout", {"ip_address": _client_ip()}, )) session.clear() return jsonify({"status": "ok"}) @auth_bp.route("/me", methods=["GET"]) @login_required async def me(): user = await get_user_by_id(get_current_user_id()) if not user: return jsonify({"error": "User not found"}), 404 return jsonify(user.to_dict()) @auth_bp.route("/password", methods=["PUT"]) @login_required async def update_password(): data = await request.get_json() current = data.get("current_password") or "" new_pw = data.get("new_password") or "" if not current: return jsonify({"error": "Current password is required"}), 400 if len(new_pw) < 8: return jsonify({"error": "New password must be at least 8 characters"}), 400 uid = get_current_user_id() new_version = await change_password(uid, current, new_pw) if new_version is None: return jsonify({"error": "Current password is incorrect"}), 403 # Keep the current session alive with the new version (other sessions are now invalidated) session["session_version"] = new_version await log_audit("password_change", user_id=uid, username=g.user.username, ip_address=_client_ip()) asyncio.create_task(notify_security_event( uid, "password_change", {"ip_address": _client_ip()}, )) return jsonify({"status": "ok"}) @auth_bp.route("/invalidate-sessions", methods=["POST"]) @login_required async def invalidate_sessions_route(): """Invalidate all other active sessions by bumping the session version. The current session is kept alive. Useful after an SSO/OAuth password change where the app has no visibility into credential rotation. """ uid = get_current_user_id() new_version = await invalidate_other_sessions(uid) session["session_version"] = new_version await log_audit("sessions_invalidated", user_id=uid, username=g.user.username, ip_address=_client_ip()) return jsonify({"status": "ok"}) @auth_bp.route("/email", methods=["PUT"]) @login_required async def update_email(): data = await request.get_json() new_email = (data.get("email") or "").strip().lower() or None password = data.get("password") or "" uid = get_current_user_id() user = await get_user_by_id(uid) if not user: return jsonify({"error": "User not found"}), 404 # Require password confirmation only for local-auth users if user.password_hash is not None: if not password: return jsonify({"error": "Password is required to change your email"}), 400 if not verify_password(password, user.password_hash): return jsonify({"error": "Incorrect password"}), 403 # Check the new email isn't taken by a different account if new_email: existing = await get_user_by_email(new_email) if existing and existing.id != uid: return jsonify({"error": "Email already in use by another account"}), 409 await update_user_email(uid, new_email) await log_audit("email_change", user_id=uid, username=user.username, ip_address=_client_ip()) updated = await get_user_by_id(uid) return jsonify(updated.to_dict()) @auth_bp.route("/forgot-password", methods=["POST"]) async def forgot_password(): if await is_rate_limited(f"forgot:{_client_ip()}", max_requests=5, window_seconds=300): return jsonify({"status": "ok"}) data = await request.get_json() email = (data.get("email") or "").strip().lower() if not email: return jsonify({"error": "Email is required"}), 400 # Always return success to prevent email enumeration user = await get_user_by_email(email) if user and await is_smtp_configured(): raw_token = await create_password_reset_token(user.id) base_url = await get_base_url() reset_url = f"{base_url}/reset-password?token={raw_token}" asyncio.create_task(send_password_reset_email(user.email, reset_url)) await log_audit( "password_reset_requested", user_id=user.id, username=user.username, ip_address=_client_ip(), ) return jsonify({"status": "ok"}) @auth_bp.route("/reset-password", methods=["POST"]) async def reset_password(): if await is_rate_limited(f"reset:{_client_ip()}", max_requests=10, window_seconds=60): return jsonify({"error": "Too many attempts. Try again later."}), 429 data = await request.get_json() token = (data.get("token") or "").strip() new_password = data.get("new_password") or "" if not token: return jsonify({"error": "Reset token is required"}), 400 if len(new_password) < 8: return jsonify({"error": "Password must be at least 8 characters"}), 400 user_id = await reset_password_with_token(token, new_password) if user_id is None: return jsonify({"error": "Invalid or expired reset link"}), 400 user = await get_user_by_id(user_id) if user: await log_audit( "password_reset_completed", user_id=user.id, username=user.username, ip_address=_client_ip(), ) if user.email: asyncio.create_task(send_password_reset_success_email(user.email)) return jsonify({"status": "ok"}) @auth_bp.route("/invitation/", methods=["GET"]) async def check_invitation(token: str): invitation = await validate_invitation_token(token) if not invitation: return jsonify({"valid": False}) return jsonify({"valid": True, "email": invitation.email}) @auth_bp.route("/register-with-invite", methods=["POST"]) async def register_with_invite(): data = await request.get_json() raw_token = (data.get("token") or "").strip() username = (data.get("username") or "").strip() password = data.get("password") or "" if not raw_token: return jsonify({"error": "Invitation token is required"}), 400 if not username: return jsonify({"error": "Username is required"}), 400 if len(password) < 8: return jsonify({"error": "Password must be at least 8 characters"}), 400 try: user = await register_with_invitation(raw_token, username, password) except Exception: return jsonify({"error": "Username already taken"}), 409 if not user: return jsonify({"error": "Invalid or expired invitation"}), 400 session["user_id"] = user.id session["session_version"] = user.session_version await log_audit( "register_with_invite", user_id=user.id, username=user.username, ip_address=_client_ip(), ) return jsonify(user.to_dict()), 201 @auth_bp.route("/status", methods=["GET"]) async def status(): count = await get_user_count() reg_open = await is_registration_open() return jsonify({ "has_users": count > 0, "registration_open": reg_open, "oauth_enabled": Config.oidc_enabled(), "local_auth_enabled": Config.LOCAL_AUTH_ENABLED, }) @auth_bp.route("/oauth/login", methods=["GET"]) async def oauth_login(): if not Config.oidc_enabled(): return jsonify({"error": "SSO is not configured"}), 404 state = secrets.token_hex(32) code_verifier = secrets.token_urlsafe(64) digest = hashlib.sha256(code_verifier.encode()).digest() code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode() session["oauth_state"] = state session["oauth_code_verifier"] = code_verifier url = await build_auth_url(state, code_challenge) return redirect(url) @auth_bp.route("/oauth/callback", methods=["GET"]) async def oauth_callback(): error = request.args.get("error") code = request.args.get("code") state = request.args.get("state") stored_state = session.pop("oauth_state", None) code_verifier = session.pop("oauth_code_verifier", None) if error or not code or state != stored_state: return redirect("/login?error=oauth") redirect_uri = Config.BASE_URL.rstrip("/") + "/api/auth/oauth/callback" try: token_response = await exchange_code(code, redirect_uri, code_verifier) claims = await get_userinfo(token_response["access_token"]) except Exception: return redirect("/login?error=oauth") sub = claims.get("sub", "") # Only trust the email claim for account linking if the provider has verified it. # An unverified email could be used to hijack an existing local account. email_verified = claims.get("email_verified", False) email = claims.get("email", "") if email_verified else "" preferred_username = claims.get("preferred_username", "") if not sub: return redirect("/login?error=oauth") user = await find_or_create_oauth_user(sub, email, preferred_username) session["user_id"] = user.id session["session_version"] = user.session_version await log_audit("oauth_login", user_id=user.id, username=user.username, ip_address=_client_ip()) return redirect("/")