from __future__ import annotations import secrets from typing import Any from urllib.parse import quote from flask import jsonify, redirect, request import psycopg def register_access_request_submission(app, deps) -> None: """Register access request submission routes.""" @app.route("/api/access/request/availability", methods=["GET"]) def request_access_availability() -> Any: """Report whether a requested username can start access signup.""" if not deps.settings.ACCESS_REQUEST_ENABLED: return jsonify({"error": "request access disabled"}), 503 if not deps.configured(): return jsonify({"error": "server not deps.configured"}), 503 username = (request.args.get("username") or "").strip() error = deps._validate_username(username) if error: return jsonify({"available": False, "reason": "invalid", "detail": error}) if deps.admin_client().ready() and deps.admin_client().find_user(username): return jsonify({"available": False, "reason": "exists", "detail": "username already exists"}) try: with deps.connect() as conn: existing = conn.execute( """ SELECT status FROM access_requests WHERE username = %s ORDER BY created_at DESC LIMIT 1 """, (username,), ).fetchone() except Exception: return jsonify({"error": "failed to check availability"}), 502 if existing: status = str(existing.get("status") or "") return jsonify( { "available": False, "reason": "requested", "status": deps._normalize_status(status), } ) return jsonify({"available": True}) @app.route("/api/access/request", methods=["POST"]) def request_access() -> Any: """Create or refresh an email-verified access request. WHY: submissions are anonymous, so this route validates names, rate limits by client/request, and emails a proof token before queuing work. """ if not deps.settings.ACCESS_REQUEST_ENABLED: return jsonify({"error": "request access disabled"}), 503 if not deps.configured(): return jsonify({"error": "server not deps.configured"}), 503 ip = deps._client_ip() username, email, note, first_name, last_name = deps._extract_request_payload() first_name = deps._normalize_name(first_name) last_name = deps._normalize_name(last_name) rate_key = ip if username: rate_key = f"{ip}:{username}" if not deps.rate_limit_allow( rate_key, key="access_request_submit", limit=deps.settings.ACCESS_REQUEST_SUBMIT_RATE_LIMIT, window_sec=deps.settings.ACCESS_REQUEST_SUBMIT_RATE_WINDOW_SEC, ): return jsonify({"error": "rate limited"}), 429 username_error = deps._validate_username(username) if username_error: return jsonify({"error": username_error}), 400 name_error = deps._validate_name(first_name, label="first name", required=False) if name_error: return jsonify({"error": name_error}), 400 name_error = deps._validate_name(last_name, label="last name", required=True) if name_error: return jsonify({"error": name_error}), 400 if not email: return jsonify({"error": "email is required"}), 400 if "@" not in email: return jsonify({"error": "invalid email"}), 400 email_lower = email.lower() if email_lower.endswith(f"@{deps.settings.MAILU_DOMAIN.lower()}") and ( email_lower not in deps.settings.ACCESS_REQUEST_INTERNAL_EMAIL_ALLOWLIST ): return jsonify({"error": "email must be an external address"}), 400 if deps.admin_client().ready() and deps.admin_client().find_user(username): return jsonify({"error": "username already exists"}), 409 if deps.admin_client().ready() and deps.admin_client().find_user_by_email(email): return jsonify({"error": "email is already associated with an existing Atlas account"}), 409 try: with deps.connect() as conn: existing = conn.execute( """ SELECT request_code, status FROM access_requests WHERE username = %s AND status IN (%s, 'pending') ORDER BY created_at DESC LIMIT 1 """, (username, deps.EMAIL_VERIFY_PENDING_STATUS), ).fetchone() if existing: existing_status = str(existing.get("status") or "") request_code = str(existing.get("request_code") or "") if existing_status != deps.EMAIL_VERIFY_PENDING_STATUS: return jsonify({"ok": True, "request_code": request_code, "status": existing_status}) token = secrets.token_urlsafe(24) token_hash = deps._hash_verification_token(token) conn.execute( """ UPDATE access_requests SET contact_email = %s, note = %s, first_name = %s, last_name = %s, email_verification_token_hash = %s, email_verification_sent_at = NOW(), email_verified_at = NULL WHERE request_code = %s AND status = %s """, ( email, note or None, first_name or None, last_name or None, token_hash, request_code, deps.EMAIL_VERIFY_PENDING_STATUS, ), ) try: deps._send_verification_email(request_code=request_code, email=email, token=token) except deps.MailerError: return ( jsonify({"error": "failed to send verification email", "request_code": request_code}), 502, ) return jsonify({"ok": True, "request_code": request_code, "status": deps.EMAIL_VERIFY_PENDING_STATUS}) request_code = deps._random_request_code(username) token = secrets.token_urlsafe(24) token_hash = deps._hash_verification_token(token) try: conn.execute( """ INSERT INTO access_requests (request_code, username, contact_email, note, first_name, last_name, status, email_verification_token_hash, email_verification_sent_at) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW()) """, ( request_code, username, email, note or None, first_name or None, last_name or None, deps.EMAIL_VERIFY_PENDING_STATUS, token_hash, ), ) except psycopg.errors.UniqueViolation: conn.rollback() existing = conn.execute( """ SELECT request_code, status FROM access_requests WHERE username = %s AND status IN (%s, 'pending') ORDER BY created_at DESC LIMIT 1 """, (username, deps.EMAIL_VERIFY_PENDING_STATUS), ).fetchone() if not existing: raise return jsonify({"ok": True, "request_code": existing["request_code"], "status": existing["status"]}) try: deps._send_verification_email(request_code=request_code, email=email, token=token) except deps.MailerError: return jsonify({"error": "failed to send verification email", "request_code": request_code}), 502 except Exception: return jsonify({"error": "failed to submit request"}), 502 return jsonify({"ok": True, "request_code": request_code, "status": deps.EMAIL_VERIFY_PENDING_STATUS}) @app.route("/api/access/request/verify", methods=["POST"]) def request_access_verify() -> Any: """Verify a submitted access request using a request code and token.""" if not deps.settings.ACCESS_REQUEST_ENABLED: return jsonify({"error": "request access disabled"}), 503 if not deps.configured(): return jsonify({"error": "server not deps.configured"}), 503 ip = deps._client_ip() if not deps.rate_limit_allow( ip, key="access_request_verify", limit=60, window_sec=60, ): return jsonify({"error": "rate limited"}), 429 payload = request.get_json(silent=True) or {} code = (payload.get("request_code") or payload.get("code") or "").strip() reveal_initial_password = bool( payload.get("reveal_initial_password") or payload.get("reveal_password") ) token = (payload.get("token") or payload.get("verify") or "").strip() if not code: return jsonify({"error": "request_code is required"}), 400 if not token: return jsonify({"error": "token is required"}), 400 if not deps.rate_limit_allow( f"{ip}:{code}", key="access_request_verify_code", limit=30, window_sec=60, ): return jsonify({"error": "rate limited"}), 429 try: with deps.connect() as conn: status = deps._verify_request(conn, code, token) return jsonify({"ok": True, "status": status}) except deps.VerificationError as exc: return jsonify({"error": exc.message}), exc.status_code except Exception: return jsonify({"error": "failed to verify"}), 502 @app.route("/api/access/request/verify-link", methods=["GET"]) def request_access_verify_link() -> Any: """Verify an emailed access-request link and redirect to the UI.""" if not deps.settings.ACCESS_REQUEST_ENABLED: return jsonify({"error": "request access disabled"}), 503 if not deps.configured(): return jsonify({"error": "server not deps.configured"}), 503 code = (request.args.get("code") or "").strip() token = (request.args.get("token") or "").strip() if not code or not token: return redirect(f"/request-access?code={quote(code)}&verify_error=missing+token") try: with deps.connect() as conn: deps._verify_request(conn, code, token) return redirect(f"/request-access?code={quote(code)}&verified=1") except deps.VerificationError as exc: return redirect(f"/request-access?code={quote(code)}&verify_error={quote(exc.message)}") except Exception: return redirect(f"/request-access?code={quote(code)}&verify_error=failed+to+verify") @app.route("/api/access/request/resend", methods=["POST"]) def request_access_resend() -> Any: """Send a fresh verification token for a pending access request.""" if not deps.settings.ACCESS_REQUEST_ENABLED: return jsonify({"error": "request access disabled"}), 503 if not deps.configured(): return jsonify({"error": "server not deps.configured"}), 503 ip = deps._client_ip() if not deps.rate_limit_allow( ip, key="access_request_resend", limit=30, window_sec=60, ): return jsonify({"error": "rate limited"}), 429 payload = request.get_json(silent=True) or {} code = (payload.get("request_code") or payload.get("code") or "").strip() if not code: return jsonify({"error": "request_code is required"}), 400 if not deps.rate_limit_allow( f"{ip}:{code}", key="access_request_resend_code", limit=10, window_sec=300, ): return jsonify({"error": "rate limited"}), 429 try: with deps.connect() as conn: row = conn.execute( """ SELECT status, contact_email FROM access_requests WHERE request_code = %s """, (code,), ).fetchone() if not row: return jsonify({"error": "not found"}), 404 status = deps._normalize_status(row.get("status") or "") if status != deps.EMAIL_VERIFY_PENDING_STATUS: return jsonify({"ok": True, "status": status}) email = str(row.get("contact_email") or "").strip() if not email: return jsonify({"error": "missing email"}), 409 token = secrets.token_urlsafe(24) token_hash = deps._hash_verification_token(token) conn.execute( """ UPDATE access_requests SET email_verification_token_hash = %s, email_verification_sent_at = NOW() WHERE request_code = %s AND status = %s """, (token_hash, code, deps.EMAIL_VERIFY_PENDING_STATUS), ) try: deps._send_verification_email(request_code=code, email=email, token=token) except deps.MailerError: return jsonify({"error": "failed to send verification email", "request_code": code}), 502 return jsonify({"ok": True, "status": deps.EMAIL_VERIFY_PENDING_STATUS}) except Exception: return jsonify({"error": "failed to resend verification"}), 502