351 lines
15 KiB
Python
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
|