bstein-dev-home/backend/atlas_portal/routes/access_request_submission.py

351 lines
15 KiB
Python

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