refactor(bstein-home): split access request routes
This commit is contained in:
parent
96d3d31b31
commit
839c2586a2
245
backend/atlas_portal/routes/access_request_onboarding.py
Normal file
245
backend/atlas_portal/routes/access_request_onboarding.py
Normal file
@ -0,0 +1,245 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from flask import jsonify, redirect, request
|
||||
|
||||
|
||||
def register_access_request_onboarding(app, deps) -> None:
|
||||
"""Register access request onboarding routes."""
|
||||
|
||||
@app.route("/api/access/request/onboarding/attest", methods=["POST"])
|
||||
def request_access_onboarding_attest() -> Any:
|
||||
if not deps.configured():
|
||||
return jsonify({"error": "server not deps.configured"}), 503
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
code = (payload.get("request_code") or payload.get("code") or "").strip()
|
||||
step = (payload.get("step") or "").strip()
|
||||
completed = payload.get("completed")
|
||||
vaultwarden_claim = bool(payload.get("vaultwarden_claim"))
|
||||
|
||||
if not code:
|
||||
return jsonify({"error": "request_code is required"}), 400
|
||||
if step not in deps.ONBOARDING_STEPS:
|
||||
return jsonify({"error": "invalid step"}), 400
|
||||
if step in deps.KEYCLOAK_MANAGED_STEPS:
|
||||
return jsonify({"error": "step is managed by keycloak"}), 400
|
||||
|
||||
username = ""
|
||||
token_groups: set[str] = set()
|
||||
bearer = request.headers.get("Authorization", "")
|
||||
if bearer:
|
||||
parts = bearer.split(None, 1)
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
return jsonify({"error": "invalid token"}), 401
|
||||
token = parts[1].strip()
|
||||
if not token:
|
||||
return jsonify({"error": "invalid token"}), 401
|
||||
try:
|
||||
claims = deps.oidc_client().verify(token)
|
||||
except Exception:
|
||||
return jsonify({"error": "invalid token"}), 401
|
||||
username = claims.get("preferred_username") or ""
|
||||
groups = claims.get("groups")
|
||||
if isinstance(groups, list):
|
||||
token_groups = {g.lstrip("/") for g in groups if isinstance(g, str) and g}
|
||||
|
||||
try:
|
||||
with deps.connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT username, status, approval_flags, contact_email FROM access_requests WHERE request_code = %s",
|
||||
(code,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
if username and (row.get("username") or "") != username:
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
status = deps._normalize_status(row.get("status") or "")
|
||||
if status not in {"awaiting_onboarding", "ready"}:
|
||||
return jsonify({"error": "onboarding not available"}), 409
|
||||
|
||||
mark_done = True
|
||||
if isinstance(completed, bool):
|
||||
mark_done = completed
|
||||
|
||||
request_username = row.get("username") or ""
|
||||
approval_flags = deps._normalize_flag_list(row.get("approval_flags"))
|
||||
contact_email = (row.get("contact_email") or "").strip()
|
||||
|
||||
if mark_done:
|
||||
prerequisites = deps.ONBOARDING_STEP_PREREQUISITES.get(step, set())
|
||||
if prerequisites:
|
||||
current_completed = deps._completed_onboarding_steps(conn, code, request_username)
|
||||
missing = sorted(prerequisites - current_completed)
|
||||
if missing:
|
||||
return jsonify({"error": "step is blocked", "blocked_by": missing}), 409
|
||||
if step in {"vaultwarden_master_password", "vaultwarden_store_temp_password"}:
|
||||
if not deps._password_rotation_requested(conn, code):
|
||||
try:
|
||||
deps._request_keycloak_password_rotation(conn, code, request_username)
|
||||
except Exception:
|
||||
return jsonify({"error": "failed to request keycloak password rotation"}), 502
|
||||
|
||||
if step == "vaultwarden_master_password":
|
||||
if vaultwarden_claim and not username:
|
||||
return jsonify({"error": "login required"}), 401
|
||||
grandfathered = (
|
||||
deps.VAULTWARDEN_GRANDFATHERED_FLAG in approval_flags
|
||||
or deps.VAULTWARDEN_GRANDFATHERED_FLAG in token_groups
|
||||
or deps._user_in_group(request_username, deps.VAULTWARDEN_GRANDFATHERED_FLAG)
|
||||
)
|
||||
if vaultwarden_claim and not grandfathered:
|
||||
return jsonify({"error": "vaultwarden claim not allowed"}), 403
|
||||
if vaultwarden_claim and not deps.admin_client().ready():
|
||||
return jsonify({"error": "keycloak admin unavailable"}), 503
|
||||
if request_username and deps.admin_client().ready():
|
||||
try:
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
if vaultwarden_claim:
|
||||
recovery_email = deps._resolve_recovery_email(request_username, contact_email)
|
||||
if not recovery_email:
|
||||
return jsonify({"error": "recovery email missing"}), 409
|
||||
deps.admin_client().set_user_attribute(
|
||||
request_username,
|
||||
"vaultwarden_email",
|
||||
recovery_email,
|
||||
)
|
||||
deps.admin_client().set_user_attribute(
|
||||
request_username,
|
||||
"vaultwarden_status",
|
||||
"grandfathered",
|
||||
)
|
||||
deps.admin_client().set_user_attribute(
|
||||
request_username,
|
||||
"vaultwarden_synced_at",
|
||||
now,
|
||||
)
|
||||
else:
|
||||
deps.admin_client().set_user_attribute(
|
||||
request_username,
|
||||
"vaultwarden_status",
|
||||
"already_present",
|
||||
)
|
||||
deps.admin_client().set_user_attribute(
|
||||
request_username,
|
||||
"vaultwarden_master_password_set_at",
|
||||
now,
|
||||
)
|
||||
except Exception:
|
||||
return jsonify({"error": "failed to update vaultwarden status"}), 502
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO access_request_onboarding_steps (request_code, step)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (request_code, step) DO NOTHING
|
||||
""",
|
||||
(code, step),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"DELETE FROM access_request_onboarding_steps WHERE request_code = %s AND step = %s",
|
||||
(code, step),
|
||||
)
|
||||
|
||||
# Re-evaluate completion to update request status to ready if applicable.
|
||||
status = deps._advance_status(conn, code, request_username, status)
|
||||
onboarding_payload = deps._onboarding_payload(conn, code, request_username)
|
||||
except Exception:
|
||||
return jsonify({"error": "failed to update onboarding"}), 502
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"status": status,
|
||||
"onboarding": onboarding_payload,
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/access/request/onboarding/keycloak-password-rotate", methods=["POST"])
|
||||
def request_access_onboarding_keycloak_password_rotate() -> Any:
|
||||
if not deps.configured():
|
||||
return jsonify({"error": "server not deps.configured"}), 503
|
||||
|
||||
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
|
||||
|
||||
token_username = ""
|
||||
bearer = request.headers.get("Authorization", "")
|
||||
if bearer:
|
||||
parts = bearer.split(None, 1)
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
return jsonify({"error": "invalid token"}), 401
|
||||
token = parts[1].strip()
|
||||
if not token:
|
||||
return jsonify({"error": "invalid token"}), 401
|
||||
try:
|
||||
claims = deps.oidc_client().verify(token)
|
||||
except Exception:
|
||||
return jsonify({"error": "invalid token"}), 401
|
||||
token_username = claims.get("preferred_username") or ""
|
||||
|
||||
if not deps.admin_client().ready():
|
||||
return jsonify({"error": "keycloak admin unavailable"}), 503
|
||||
|
||||
try:
|
||||
with deps.connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT username, status FROM access_requests WHERE request_code = %s",
|
||||
(code,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
request_username = row.get("username") or ""
|
||||
if token_username and request_username != token_username:
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
status = deps._normalize_status(row.get("status") or "")
|
||||
if status not in {"awaiting_onboarding", "ready"}:
|
||||
return jsonify({"error": "onboarding not available"}), 409
|
||||
|
||||
prerequisites = deps.ONBOARDING_STEP_PREREQUISITES.get("keycloak_password_rotated", set())
|
||||
if prerequisites:
|
||||
current_completed = deps._completed_onboarding_steps(conn, code, request_username)
|
||||
missing = sorted(prerequisites - current_completed)
|
||||
if missing:
|
||||
return jsonify({"error": "step is blocked", "blocked_by": missing}), 409
|
||||
|
||||
user = deps.admin_client().find_user(request_username) or {}
|
||||
user_id = user.get("id") if isinstance(user, dict) else None
|
||||
if not isinstance(user_id, str) or not user_id:
|
||||
return jsonify({"error": "keycloak user not found"}), 409
|
||||
|
||||
full = deps.admin_client().get_user(user_id)
|
||||
actions = full.get("requiredActions")
|
||||
actions_list: list[str] = []
|
||||
if isinstance(actions, list):
|
||||
actions_list = [a for a in actions if isinstance(a, str)]
|
||||
|
||||
rotation_requested = deps._password_rotation_requested(conn, code)
|
||||
already_rotated = rotation_requested and "UPDATE_PASSWORD" not in actions_list
|
||||
|
||||
if not already_rotated:
|
||||
if "UPDATE_PASSWORD" not in actions_list:
|
||||
actions_list.append("UPDATE_PASSWORD")
|
||||
deps.admin_client().update_user_safe(user_id, {"requiredActions": actions_list})
|
||||
if not rotation_requested:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO access_request_onboarding_artifacts (request_code, artifact, value_hash)
|
||||
VALUES (%s, %s, NOW()::text)
|
||||
ON CONFLICT (request_code, artifact) DO NOTHING
|
||||
""",
|
||||
(code, deps._KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT),
|
||||
)
|
||||
|
||||
onboarding_payload = deps._onboarding_payload(conn, code, request_username)
|
||||
except Exception:
|
||||
return jsonify({"error": "failed to request password rotation"}), 502
|
||||
|
||||
return jsonify({"ok": True, "status": status, "onboarding": onboarding_payload})
|
||||
@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Onboarding step policy for access requests."""
|
||||
|
||||
ONBOARDING_STEPS: tuple[str, ...] = (
|
||||
"vaultwarden_master_password",
|
||||
"vaultwarden_store_temp_password",
|
||||
"vaultwarden_browser_extension",
|
||||
"vaultwarden_mobile_app",
|
||||
"keycloak_password_rotated",
|
||||
"element_recovery_key",
|
||||
"element_mobile_app",
|
||||
"mail_client_setup",
|
||||
"nextcloud_web_access",
|
||||
"nextcloud_mail_integration",
|
||||
"nextcloud_desktop_app",
|
||||
"nextcloud_mobile_app",
|
||||
"budget_encryption_ack",
|
||||
"firefly_password_rotated",
|
||||
"firefly_mobile_app",
|
||||
"wger_password_rotated",
|
||||
"wger_mobile_app",
|
||||
"jellyfin_web_access",
|
||||
"jellyfin_mobile_app",
|
||||
"jellyfin_tv_setup",
|
||||
)
|
||||
|
||||
ONBOARDING_OPTIONAL_STEPS: set[str] = {
|
||||
"element_mobile_app",
|
||||
"nextcloud_desktop_app",
|
||||
"nextcloud_mobile_app",
|
||||
"firefly_mobile_app",
|
||||
"jellyfin_mobile_app",
|
||||
"jellyfin_tv_setup",
|
||||
}
|
||||
ONBOARDING_REQUIRED_STEPS: tuple[str, ...] = (
|
||||
"vaultwarden_master_password",
|
||||
"vaultwarden_browser_extension",
|
||||
"vaultwarden_mobile_app",
|
||||
"keycloak_password_rotated",
|
||||
"element_recovery_key",
|
||||
"mail_client_setup",
|
||||
"nextcloud_web_access",
|
||||
"nextcloud_mail_integration",
|
||||
"budget_encryption_ack",
|
||||
"firefly_password_rotated",
|
||||
"wger_password_rotated",
|
||||
"jellyfin_web_access",
|
||||
)
|
||||
|
||||
KEYCLOAK_MANAGED_STEPS: set[str] = {
|
||||
"keycloak_password_rotated",
|
||||
"nextcloud_mail_integration",
|
||||
}
|
||||
_KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT = "keycloak_password_rotation_requested_at"
|
||||
|
||||
ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = {
|
||||
"vaultwarden_master_password": set(),
|
||||
"vaultwarden_store_temp_password": {"vaultwarden_master_password"},
|
||||
"vaultwarden_browser_extension": {"vaultwarden_master_password"},
|
||||
"vaultwarden_mobile_app": {"vaultwarden_master_password"},
|
||||
"keycloak_password_rotated": {"vaultwarden_master_password"},
|
||||
"element_recovery_key": {"keycloak_password_rotated"},
|
||||
"element_mobile_app": {"element_recovery_key"},
|
||||
"mail_client_setup": {"vaultwarden_master_password"},
|
||||
"nextcloud_web_access": {"vaultwarden_master_password"},
|
||||
"nextcloud_mail_integration": {"nextcloud_web_access"},
|
||||
"nextcloud_desktop_app": {"nextcloud_web_access"},
|
||||
"nextcloud_mobile_app": {"nextcloud_web_access"},
|
||||
"budget_encryption_ack": {"nextcloud_mail_integration"},
|
||||
"firefly_password_rotated": {"element_recovery_key"},
|
||||
"wger_password_rotated": {"firefly_password_rotated"},
|
||||
"wger_mobile_app": {"wger_password_rotated"},
|
||||
"jellyfin_web_access": {"vaultwarden_master_password"},
|
||||
"jellyfin_mobile_app": {"jellyfin_web_access"},
|
||||
"jellyfin_tv_setup": {"jellyfin_web_access"},
|
||||
}
|
||||
|
||||
VAULTWARDEN_GRANDFATHERED_FLAG = "vaultwarden_grandfathered"
|
||||
_VAULTWARDEN_READY_STATUSES = {"already_present", "active", "ready", "grandfathered"}
|
||||
479
backend/atlas_portal/routes/access_request_state.py
Normal file
479
backend/atlas_portal/routes/access_request_state.py
Normal file
@ -0,0 +1,479 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
import secrets
|
||||
import string
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from flask import request
|
||||
|
||||
from .. import ariadne_client
|
||||
from ..db import connect, configured
|
||||
from ..keycloak import admin_client, oidc_client
|
||||
from ..mailer import MailerError, access_request_verification_body, send_text_email
|
||||
from ..rate_limit import rate_limit_allow
|
||||
from ..provisioning import provision_access_request, provision_tasks_complete
|
||||
from .. import settings
|
||||
from .access_request_onboarding_policy import (
|
||||
KEYCLOAK_MANAGED_STEPS,
|
||||
ONBOARDING_OPTIONAL_STEPS,
|
||||
ONBOARDING_REQUIRED_STEPS,
|
||||
ONBOARDING_STEP_PREREQUISITES,
|
||||
ONBOARDING_STEPS,
|
||||
VAULTWARDEN_GRANDFATHERED_FLAG,
|
||||
_KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT,
|
||||
_VAULTWARDEN_READY_STATUSES,
|
||||
)
|
||||
|
||||
def _extract_request_payload() -> tuple[str, str, str, str, str]:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
username = (payload.get("username") or "").strip()
|
||||
email = (payload.get("email") or "").strip()
|
||||
note = (payload.get("note") or "").strip()
|
||||
first_name = (payload.get("first_name") or "").strip()
|
||||
last_name = (payload.get("last_name") or "").strip()
|
||||
return username, email, note, first_name, last_name
|
||||
|
||||
|
||||
def _normalize_name(value: str) -> str:
|
||||
return " ".join(value.strip().split())
|
||||
|
||||
|
||||
def _validate_name(value: str, *, label: str, required: bool) -> str | None:
|
||||
cleaned = _normalize_name(value)
|
||||
if not cleaned:
|
||||
return f"{label} is required" if required else None
|
||||
if len(cleaned) > 80:
|
||||
return f"{label} must be 1-80 characters"
|
||||
if any(ch in "\r\n\t" for ch in cleaned):
|
||||
return f"{label} contains invalid whitespace"
|
||||
return None
|
||||
|
||||
|
||||
def _validate_username(username: str) -> str | None:
|
||||
if not username:
|
||||
return "username is required"
|
||||
if len(username) < 3 or len(username) > 32:
|
||||
return "username must be 3-32 characters"
|
||||
if not re.fullmatch(r"[a-zA-Z0-9._-]+", username):
|
||||
return "username contains invalid characters"
|
||||
return None
|
||||
|
||||
|
||||
def _random_request_code(username: str) -> str:
|
||||
suffix = "".join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(10))
|
||||
return f"{username}~{suffix}"
|
||||
|
||||
|
||||
def _client_ip() -> str:
|
||||
xff = (request.headers.get("X-Forwarded-For") or "").strip()
|
||||
if xff:
|
||||
return xff.split(",", 1)[0].strip() or "unknown"
|
||||
x_real_ip = (request.headers.get("X-Real-IP") or "").strip()
|
||||
if x_real_ip:
|
||||
return x_real_ip
|
||||
return request.remote_addr or "unknown"
|
||||
|
||||
|
||||
EMAIL_VERIFY_PENDING_STATUS = "pending_email_verification"
|
||||
|
||||
|
||||
def _hash_verification_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _verify_url(request_code: str, token: str) -> str:
|
||||
base = settings.PORTAL_PUBLIC_BASE_URL.rstrip("/")
|
||||
return f"{base}/api/access/request/verify-link?code={quote(request_code)}&token={quote(token)}"
|
||||
|
||||
|
||||
def _send_verification_email(*, request_code: str, email: str, token: str) -> None:
|
||||
verify_url = _verify_url(request_code, token)
|
||||
send_text_email(
|
||||
to_addr=email,
|
||||
subject="Atlas: confirm your email",
|
||||
body=access_request_verification_body(request_code=request_code, verify_url=verify_url),
|
||||
)
|
||||
|
||||
|
||||
class VerificationError(Exception):
|
||||
def __init__(self, status_code: int, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
|
||||
|
||||
def _verify_request(conn, code: str, token: str) -> str:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT status, email_verification_token_hash, email_verification_sent_at, email_verified_at
|
||||
FROM access_requests
|
||||
WHERE request_code = %s
|
||||
""",
|
||||
(code,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise VerificationError(404, "not found")
|
||||
|
||||
status = _normalize_status(row.get("status") or "")
|
||||
if status != EMAIL_VERIFY_PENDING_STATUS:
|
||||
return status
|
||||
|
||||
stored_hash = str(row.get("email_verification_token_hash") or "")
|
||||
if not stored_hash:
|
||||
raise VerificationError(409, "verification token missing")
|
||||
|
||||
provided_hash = _hash_verification_token(token)
|
||||
if not hmac.compare_digest(stored_hash, provided_hash):
|
||||
raise VerificationError(401, "invalid token")
|
||||
|
||||
sent_at = row.get("email_verification_sent_at")
|
||||
if isinstance(sent_at, datetime):
|
||||
now = datetime.now(timezone.utc)
|
||||
if sent_at.tzinfo is None:
|
||||
sent_at = sent_at.replace(tzinfo=timezone.utc)
|
||||
age_sec = (now - sent_at).total_seconds()
|
||||
if age_sec > settings.ACCESS_REQUEST_EMAIL_VERIFY_TTL_SEC:
|
||||
raise VerificationError(410, "verification token expired")
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE access_requests
|
||||
SET status = 'pending',
|
||||
email_verified_at = NOW(),
|
||||
email_verification_token_hash = NULL
|
||||
WHERE request_code = %s AND status = %s
|
||||
""",
|
||||
(code, EMAIL_VERIFY_PENDING_STATUS),
|
||||
)
|
||||
return "pending"
|
||||
|
||||
|
||||
def _normalize_status(status: str) -> str:
|
||||
cleaned = (status or "").strip().lower()
|
||||
if cleaned == "approved":
|
||||
return "accounts_building"
|
||||
return cleaned or "unknown"
|
||||
|
||||
|
||||
def _fetch_completed_onboarding_steps(conn, request_code: str) -> set[str]:
|
||||
rows = conn.execute(
|
||||
"SELECT step FROM access_request_onboarding_steps WHERE request_code = %s",
|
||||
(request_code,),
|
||||
).fetchall()
|
||||
completed: set[str] = set()
|
||||
for row in rows:
|
||||
step = row.get("step") if isinstance(row, dict) else None
|
||||
if isinstance(step, str) and step:
|
||||
completed.add(step)
|
||||
return completed
|
||||
|
||||
|
||||
def _normalize_flag_list(raw: Any) -> set[str]:
|
||||
if isinstance(raw, list):
|
||||
return {item for item in raw if isinstance(item, str) and item}
|
||||
if isinstance(raw, str) and raw:
|
||||
return {raw}
|
||||
return set()
|
||||
|
||||
|
||||
def _fetch_request_flags_and_email(conn, request_code: str) -> tuple[set[str], str]:
|
||||
row = conn.execute(
|
||||
"SELECT approval_flags, contact_email FROM access_requests WHERE request_code = %s",
|
||||
(request_code,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return set(), ""
|
||||
flags = _normalize_flag_list(row.get("approval_flags"))
|
||||
email = row.get("contact_email") if isinstance(row, dict) else ""
|
||||
return flags, (email or "").strip()
|
||||
|
||||
|
||||
def _user_in_group(username: str, group_name: str) -> bool:
|
||||
if not username or not group_name:
|
||||
return False
|
||||
if not admin_client().ready():
|
||||
return False
|
||||
try:
|
||||
user = admin_client().find_user(username) or {}
|
||||
user_id = user.get("id") if isinstance(user, dict) else None
|
||||
if not isinstance(user_id, str) or not user_id:
|
||||
return False
|
||||
groups = admin_client().list_user_groups(user_id)
|
||||
except Exception:
|
||||
return False
|
||||
return group_name in groups
|
||||
|
||||
|
||||
def _vaultwarden_grandfathered(conn, request_code: str, username: str) -> tuple[bool, str]:
|
||||
flags, contact_email = _fetch_request_flags_and_email(conn, request_code)
|
||||
if VAULTWARDEN_GRANDFATHERED_FLAG in flags:
|
||||
return True, contact_email
|
||||
if _user_in_group(username, VAULTWARDEN_GRANDFATHERED_FLAG):
|
||||
return True, contact_email
|
||||
return False, contact_email
|
||||
|
||||
|
||||
def _resolve_recovery_email(username: str, fallback: str) -> str:
|
||||
if username and admin_client().ready():
|
||||
try:
|
||||
user = admin_client().find_user(username) or {}
|
||||
user_id = user.get("id") if isinstance(user, dict) else None
|
||||
if isinstance(user_id, str) and user_id:
|
||||
full = admin_client().get_user(user_id)
|
||||
email = full.get("email")
|
||||
if isinstance(email, str) and email.strip():
|
||||
return email.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return (fallback or "").strip()
|
||||
|
||||
|
||||
def _password_rotation_requested(conn, request_code: str) -> bool:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM access_request_onboarding_artifacts
|
||||
WHERE request_code = %s AND artifact = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(request_code, _KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT),
|
||||
).fetchone()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def _request_keycloak_password_rotation(conn, request_code: str, username: str) -> None:
|
||||
if not username:
|
||||
raise ValueError("username missing")
|
||||
if not admin_client().ready():
|
||||
raise RuntimeError("keycloak admin unavailable")
|
||||
|
||||
user = admin_client().find_user(username) or {}
|
||||
user_id = user.get("id") if isinstance(user, dict) else None
|
||||
if not isinstance(user_id, str) or not user_id:
|
||||
raise RuntimeError("keycloak user not found")
|
||||
|
||||
full = admin_client().get_user(user_id)
|
||||
actions = full.get("requiredActions")
|
||||
actions_list: list[str] = []
|
||||
if isinstance(actions, list):
|
||||
actions_list = [a for a in actions if isinstance(a, str)]
|
||||
if "UPDATE_PASSWORD" not in actions_list:
|
||||
actions_list.append("UPDATE_PASSWORD")
|
||||
admin_client().update_user_safe(user_id, {"requiredActions": actions_list})
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO access_request_onboarding_artifacts (request_code, artifact, value_hash)
|
||||
VALUES (%s, %s, NOW()::text)
|
||||
ON CONFLICT (request_code, artifact) DO NOTHING
|
||||
""",
|
||||
(request_code, _KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT),
|
||||
)
|
||||
|
||||
|
||||
def _extract_attr(attrs: Any, key: str) -> str:
|
||||
if not isinstance(attrs, dict):
|
||||
return ""
|
||||
raw = attrs.get(key)
|
||||
if isinstance(raw, list):
|
||||
for item in raw:
|
||||
if isinstance(item, str) and item.strip():
|
||||
return item.strip()
|
||||
return ""
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
return raw.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _vaultwarden_status_for_user(username: str) -> str:
|
||||
if not username:
|
||||
return ""
|
||||
if not admin_client().ready():
|
||||
return ""
|
||||
try:
|
||||
user = admin_client().find_user(username) or {}
|
||||
user_id = user.get("id") if isinstance(user, dict) else None
|
||||
if not isinstance(user_id, str) or not user_id:
|
||||
return ""
|
||||
full = admin_client().get_user(user_id)
|
||||
attrs = full.get("attributes") if isinstance(full, dict) else {}
|
||||
return _extract_attr(attrs, "vaultwarden_status")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _auto_completed_service_steps(attrs: Any) -> set[str]:
|
||||
completed: set[str] = set()
|
||||
if not isinstance(attrs, dict):
|
||||
return completed
|
||||
|
||||
vaultwarden_status = _extract_attr(attrs, "vaultwarden_status")
|
||||
vaultwarden_master = _extract_attr(attrs, "vaultwarden_master_password_set_at")
|
||||
if vaultwarden_master or vaultwarden_status in _VAULTWARDEN_READY_STATUSES:
|
||||
completed.add("vaultwarden_master_password")
|
||||
|
||||
nextcloud_synced_at = _extract_attr(attrs, "nextcloud_mail_synced_at")
|
||||
if nextcloud_synced_at:
|
||||
completed.add("nextcloud_mail_integration")
|
||||
|
||||
firefly_rotated_at = _extract_attr(attrs, "firefly_password_rotated_at")
|
||||
if firefly_rotated_at:
|
||||
completed.add("firefly_password_rotated")
|
||||
|
||||
wger_rotated_at = _extract_attr(attrs, "wger_password_rotated_at")
|
||||
if wger_rotated_at:
|
||||
completed.add("wger_password_rotated")
|
||||
|
||||
return completed
|
||||
|
||||
|
||||
def _auto_completed_keycloak_steps(conn, request_code: str, username: str) -> set[str]:
|
||||
if not username:
|
||||
return set()
|
||||
if not admin_client().ready():
|
||||
return set()
|
||||
if not request_code:
|
||||
return set()
|
||||
|
||||
completed: set[str] = set()
|
||||
try:
|
||||
user = admin_client().find_user(username) or {}
|
||||
user_id = user.get("id") if isinstance(user, dict) else None
|
||||
if not isinstance(user_id, str) or not user_id:
|
||||
return set()
|
||||
|
||||
full = {}
|
||||
try:
|
||||
full = admin_client().get_user(user_id)
|
||||
except Exception:
|
||||
full = user if isinstance(user, dict) else {}
|
||||
|
||||
attrs = full.get("attributes") if isinstance(full, dict) else {}
|
||||
completed |= _auto_completed_service_steps(attrs)
|
||||
|
||||
actions = full.get("requiredActions")
|
||||
required_actions: set[str] = set()
|
||||
actions_list: list[str] = []
|
||||
if isinstance(actions, list):
|
||||
actions_list = [a for a in actions if isinstance(a, str)]
|
||||
required_actions = set(actions_list)
|
||||
|
||||
if _password_rotation_requested(conn, request_code) and "UPDATE_PASSWORD" not in required_actions:
|
||||
completed.add("keycloak_password_rotated")
|
||||
|
||||
# Backfill: earlier accounts were created with CONFIGURE_TOTP as a required action,
|
||||
# which forces users to enroll MFA at first login. We no longer require that, so
|
||||
# remove it if present.
|
||||
if "CONFIGURE_TOTP" in required_actions:
|
||||
try:
|
||||
admin_client().update_user_safe(
|
||||
user_id,
|
||||
{"requiredActions": [a for a in actions_list if a != "CONFIGURE_TOTP"]},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
return completed
|
||||
|
||||
|
||||
def _completed_onboarding_steps(conn, request_code: str, username: str) -> set[str]:
|
||||
completed = _fetch_completed_onboarding_steps(conn, request_code)
|
||||
return completed | _auto_completed_keycloak_steps(conn, request_code, username)
|
||||
|
||||
|
||||
def _automation_ready(conn, request_code: str, username: str) -> bool:
|
||||
if not username:
|
||||
return False
|
||||
if not admin_client().ready():
|
||||
return False
|
||||
|
||||
# Prefer task-based readiness when we have task rows for the request.
|
||||
task_row = conn.execute(
|
||||
"SELECT 1 FROM access_request_tasks WHERE request_code = %s LIMIT 1",
|
||||
(request_code,),
|
||||
).fetchone()
|
||||
if task_row:
|
||||
return provision_tasks_complete(conn, request_code)
|
||||
|
||||
# Fallback for legacy requests: confirm user exists and has a mail app password.
|
||||
try:
|
||||
user = admin_client().find_user(username)
|
||||
if not user:
|
||||
return False
|
||||
user_id = user.get("id") if isinstance(user, dict) else None
|
||||
if not user_id:
|
||||
return False
|
||||
full = admin_client().get_user(str(user_id))
|
||||
attrs = full.get("attributes") or {}
|
||||
if not isinstance(attrs, dict):
|
||||
return False
|
||||
raw_pw = attrs.get("mailu_app_password")
|
||||
if isinstance(raw_pw, list):
|
||||
return bool(raw_pw and isinstance(raw_pw[0], str) and raw_pw[0])
|
||||
return bool(isinstance(raw_pw, str) and raw_pw)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _advance_status(conn, request_code: str, username: str, status: str) -> str:
|
||||
status = _normalize_status(status)
|
||||
|
||||
if status == "accounts_building" and _automation_ready(conn, request_code, username):
|
||||
conn.execute(
|
||||
"UPDATE access_requests SET status = 'awaiting_onboarding' WHERE request_code = %s AND status = 'accounts_building'",
|
||||
(request_code,),
|
||||
)
|
||||
return "awaiting_onboarding"
|
||||
|
||||
if status == "awaiting_onboarding":
|
||||
completed = _completed_onboarding_steps(conn, request_code, username)
|
||||
required_steps = set(ONBOARDING_REQUIRED_STEPS)
|
||||
grandfathered, _ = _vaultwarden_grandfathered(conn, request_code, username)
|
||||
vaultwarden_status = _vaultwarden_status_for_user(username)
|
||||
if grandfathered and vaultwarden_status == "grandfathered":
|
||||
required_steps.add("vaultwarden_store_temp_password")
|
||||
if required_steps.issubset(completed):
|
||||
conn.execute(
|
||||
"UPDATE access_requests SET status = 'ready' WHERE request_code = %s AND status = 'awaiting_onboarding'",
|
||||
(request_code,),
|
||||
)
|
||||
return "ready"
|
||||
|
||||
return status
|
||||
|
||||
|
||||
def _onboarding_payload(conn, request_code: str, username: str) -> dict[str, Any]:
|
||||
completed_steps = sorted(_completed_onboarding_steps(conn, request_code, username))
|
||||
password_rotation_requested = _password_rotation_requested(conn, request_code)
|
||||
grandfathered, contact_email = _vaultwarden_grandfathered(conn, request_code, username)
|
||||
recovery_email = _resolve_recovery_email(username, contact_email) if grandfathered else ""
|
||||
vaultwarden_status = _vaultwarden_status_for_user(username)
|
||||
vaultwarden_matched = grandfathered and vaultwarden_status == "grandfathered"
|
||||
required_steps = list(ONBOARDING_REQUIRED_STEPS)
|
||||
if vaultwarden_matched:
|
||||
required_steps.append("vaultwarden_store_temp_password")
|
||||
return {
|
||||
"required_steps": required_steps,
|
||||
"optional_steps": sorted(ONBOARDING_OPTIONAL_STEPS),
|
||||
"completed_steps": completed_steps,
|
||||
"keycloak": {
|
||||
"password_rotation_requested": password_rotation_requested,
|
||||
},
|
||||
"vaultwarden": {
|
||||
"grandfathered": grandfathered,
|
||||
"recovery_email": recovery_email,
|
||||
"matched": vaultwarden_matched,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Keep the historical access_requests module patch surface intact for tests and
|
||||
# callers while the route handlers live in smaller focused modules.
|
||||
__all__ = [name for name in globals() if not name.startswith("__")]
|
||||
220
backend/atlas_portal/routes/access_request_status.py
Normal file
220
backend/atlas_portal/routes/access_request_status.py
Normal file
@ -0,0 +1,220 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from flask import jsonify, redirect, request
|
||||
|
||||
|
||||
def register_access_request_status(app, deps) -> None:
|
||||
"""Register access request status routes."""
|
||||
|
||||
@app.route("/api/access/request/status", methods=["POST"])
|
||||
def request_access_status() -> Any:
|
||||
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_status",
|
||||
limit=deps.settings.ACCESS_REQUEST_STATUS_RATE_LIMIT,
|
||||
window_sec=deps.settings.ACCESS_REQUEST_STATUS_RATE_WINDOW_SEC,
|
||||
):
|
||||
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")
|
||||
)
|
||||
if not code:
|
||||
return jsonify({"error": "request_code is required"}), 400
|
||||
|
||||
# Additional per-code limiter to avoid global NAT rate-limit blowups.
|
||||
if not deps.rate_limit_allow(
|
||||
f"{ip}:{code}",
|
||||
key="access_request_status_code",
|
||||
limit=max(20, deps.settings.ACCESS_REQUEST_STATUS_RATE_LIMIT),
|
||||
window_sec=deps.settings.ACCESS_REQUEST_STATUS_RATE_WINDOW_SEC,
|
||||
):
|
||||
return jsonify({"error": "rate limited"}), 429
|
||||
|
||||
try:
|
||||
with deps.connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT status,
|
||||
username,
|
||||
initial_password,
|
||||
initial_password_revealed_at,
|
||||
email_verified_at
|
||||
FROM access_requests
|
||||
WHERE request_code = %s
|
||||
""",
|
||||
(code,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
current_status = deps._normalize_status(row.get("status") or "")
|
||||
if current_status == "accounts_building" and not deps.ariadne_client.enabled():
|
||||
try:
|
||||
deps.provision_access_request(code)
|
||||
except Exception:
|
||||
pass
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT status,
|
||||
username,
|
||||
initial_password,
|
||||
initial_password_revealed_at,
|
||||
email_verified_at
|
||||
FROM access_requests
|
||||
WHERE request_code = %s
|
||||
""",
|
||||
(code,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
|
||||
status = deps._advance_status(conn, code, row.get("username") or "", row.get("status") or "")
|
||||
response: dict[str, Any] = {
|
||||
"ok": True,
|
||||
"status": status,
|
||||
"username": row.get("username") or "",
|
||||
"email_verified": bool(row.get("email_verified_at")),
|
||||
}
|
||||
task_rows = conn.execute(
|
||||
"""
|
||||
SELECT task, status, detail, updated_at
|
||||
FROM access_request_tasks
|
||||
WHERE request_code = %s
|
||||
ORDER BY task
|
||||
""",
|
||||
(code,),
|
||||
).fetchall()
|
||||
if task_rows:
|
||||
tasks: list[dict[str, Any]] = []
|
||||
blocked = False
|
||||
for task_row in task_rows:
|
||||
task_name = task_row.get("task") if isinstance(task_row, dict) else None
|
||||
task_status = task_row.get("status") if isinstance(task_row, dict) else None
|
||||
detail = task_row.get("detail") if isinstance(task_row, dict) else None
|
||||
updated_at = task_row.get("updated_at") if isinstance(task_row, dict) else None
|
||||
|
||||
if isinstance(task_status, str) and task_status == "error":
|
||||
blocked = True
|
||||
|
||||
task_payload: dict[str, Any] = {
|
||||
"task": task_name or "",
|
||||
"status": task_status or "",
|
||||
}
|
||||
if isinstance(detail, str) and detail:
|
||||
task_payload["detail"] = detail
|
||||
if isinstance(updated_at, datetime):
|
||||
task_payload["updated_at"] = updated_at.astimezone(timezone.utc).isoformat()
|
||||
tasks.append(task_payload)
|
||||
|
||||
response["tasks"] = tasks
|
||||
response["automation_complete"] = deps.provision_tasks_complete(conn, code)
|
||||
response["blocked"] = blocked
|
||||
if status in {"awaiting_onboarding", "ready"}:
|
||||
revealed_at = row.get("initial_password_revealed_at")
|
||||
if isinstance(revealed_at, datetime):
|
||||
response["initial_password_revealed_at"] = revealed_at.astimezone(timezone.utc).isoformat()
|
||||
if reveal_initial_password:
|
||||
password = row.get("initial_password")
|
||||
if isinstance(password, str) and password and revealed_at is None:
|
||||
response["initial_password"] = password
|
||||
conn.execute(
|
||||
"UPDATE access_requests SET initial_password_revealed_at = NOW() WHERE request_code = %s AND initial_password_revealed_at IS NULL",
|
||||
(code,),
|
||||
)
|
||||
if status in {"awaiting_onboarding", "ready"}:
|
||||
response["onboarding_url"] = f"/onboarding?code={code}"
|
||||
if status in {"awaiting_onboarding", "ready"}:
|
||||
response["onboarding"] = deps._onboarding_payload(conn, code, row.get("username") or "")
|
||||
return jsonify(response)
|
||||
except Exception:
|
||||
return jsonify({"error": "failed to load status"}), 502
|
||||
|
||||
@app.route("/api/access/request/retry", methods=["POST"])
|
||||
def request_access_retry() -> Any:
|
||||
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_retry",
|
||||
limit=deps.settings.ACCESS_REQUEST_STATUS_RATE_LIMIT,
|
||||
window_sec=deps.settings.ACCESS_REQUEST_STATUS_RATE_WINDOW_SEC,
|
||||
):
|
||||
return jsonify({"error": "rate limited"}), 429
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
code = (payload.get("request_code") or payload.get("code") or "").strip()
|
||||
tasks = payload.get("tasks")
|
||||
task_list = [task for task in tasks if isinstance(task, str) and task.strip()] if isinstance(tasks, list) else []
|
||||
if not code:
|
||||
return jsonify({"error": "request_code is required"}), 400
|
||||
|
||||
if deps.ariadne_client.enabled():
|
||||
retry_payload = {"tasks": task_list} if task_list else None
|
||||
return deps.ariadne_client.proxy(
|
||||
"POST",
|
||||
f"/api/access/requests/{code}/retry",
|
||||
payload=retry_payload,
|
||||
)
|
||||
|
||||
try:
|
||||
with deps.connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT status FROM access_requests WHERE request_code = %s",
|
||||
(code,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
status = row.get("status") or ""
|
||||
if status not in {"accounts_building", "approved"}:
|
||||
return jsonify({"error": "request not retryable"}), 409
|
||||
conn.execute(
|
||||
"UPDATE access_requests SET provision_attempted_at = NULL WHERE request_code = %s",
|
||||
(code,),
|
||||
)
|
||||
if task_list:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE access_request_tasks
|
||||
SET status = 'pending',
|
||||
detail = 'retry requested',
|
||||
updated_at = NOW()
|
||||
WHERE request_code = %s
|
||||
AND task = ANY(%s::text[])
|
||||
AND status = 'error'
|
||||
""",
|
||||
(code, task_list),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE access_request_tasks
|
||||
SET status = 'pending',
|
||||
detail = 'retry requested',
|
||||
updated_at = NOW()
|
||||
WHERE request_code = %s AND status = 'error'
|
||||
""",
|
||||
(code,),
|
||||
)
|
||||
except Exception:
|
||||
return jsonify({"error": "failed to retry request"}), 502
|
||||
|
||||
try:
|
||||
deps.provision_access_request(code)
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({"ok": True, "status": "accounts_building"})
|
||||
336
backend/atlas_portal/routes/access_request_submission.py
Normal file
336
backend/atlas_portal/routes/access_request_submission.py
Normal file
@ -0,0 +1,336 @@
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user