diff --git a/backend/atlas_portal/routes/access_requests.py b/backend/atlas_portal/routes/access_requests.py index 73ffbc6..e9d331f 100644 --- a/backend/atlas_portal/routes/access_requests.py +++ b/backend/atlas_portal/routes/access_requests.py @@ -9,7 +9,7 @@ import string from typing import Any from urllib.parse import quote -from flask import jsonify, request, g +from flask import jsonify, request, g, redirect import psycopg @@ -81,7 +81,7 @@ def _hash_verification_token(token: str) -> str: def _verify_url(request_code: str, token: str) -> str: base = settings.PORTAL_PUBLIC_BASE_URL.rstrip("/") - return f"{base}/request-access?code={quote(request_code)}&verify={quote(token)}" + 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: @@ -93,6 +93,59 @@ def _send_verification_email(*, request_code: str, email: str, token: str) -> No ) +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" + + ONBOARDING_STEPS: tuple[str, ...] = ( "vaultwarden_master_password", "keycloak_password_rotated", @@ -561,52 +614,34 @@ def register(app) -> None: try: with connect() as conn: - 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: - return jsonify({"error": "not found"}), 404 - - status = _normalize_status(row.get("status") or "") - if status != EMAIL_VERIFY_PENDING_STATUS: - return jsonify({"ok": True, "status": status}) - - stored_hash = str(row.get("email_verification_token_hash") or "") - if not stored_hash: - return jsonify({"error": "verification token missing"}), 409 - - provided_hash = _hash_verification_token(token) - if not hmac.compare_digest(stored_hash, provided_hash): - return jsonify({"error": "invalid token"}), 401 - - 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: - return jsonify({"error": "verification token expired"}), 410 - - 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 jsonify({"ok": True, "status": "pending"}) + status = _verify_request(conn, code, token) + return jsonify({"ok": True, "status": status}) + except 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 settings.ACCESS_REQUEST_ENABLED: + return jsonify({"error": "request access disabled"}), 503 + if not configured(): + return jsonify({"error": "server not 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 connect() as conn: + _verify_request(conn, code, token) + return redirect(f"/request-access?code={quote(code)}&verified=1") + except 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 settings.ACCESS_REQUEST_ENABLED: diff --git a/backend/tests/test_access_requests.py b/backend/tests/test_access_requests.py index c8aa154..defcbbe 100644 --- a/backend/tests/test_access_requests.py +++ b/backend/tests/test_access_requests.py @@ -179,3 +179,30 @@ class AccessRequestTests(TestCase): self.assertEqual(data.get("status"), "pending_email_verification") self.assertEqual(sent.get("request_code"), "alice~CODE123") self.assertEqual(sent.get("email"), "alice@example.com") + + def test_verify_request_updates_status(self): + token = "tok-123" + rows = { + "SELECT status, email_verification_token_hash": { + "status": "pending_email_verification", + "email_verification_token_hash": ar._hash_verification_token(token), + "email_verification_sent_at": ar.datetime.now(ar.timezone.utc), + } + } + with dummy_connect(rows) as conn: + status = ar._verify_request(conn, "alice~CODE123", token) + self.assertEqual(status, "pending") + + def test_verify_link_redirects(self): + token = "tok-123" + rows = { + "SELECT status, email_verification_token_hash": { + "status": "pending_email_verification", + "email_verification_token_hash": ar._hash_verification_token(token), + "email_verification_sent_at": ar.datetime.now(ar.timezone.utc), + } + } + with mock.patch.object(ar, "connect", lambda: dummy_connect(rows)): + resp = self.client.get(f"/api/access/request/verify-link?code=alice~CODE123&token={token}") + self.assertEqual(resp.status_code, 302) + self.assertIn("verified=1", resp.headers.get("Location", "")) diff --git a/frontend/src/views/RequestAccessView.vue b/frontend/src/views/RequestAccessView.vue index a1cadbc..48add7d 100644 --- a/frontend/src/views/RequestAccessView.vue +++ b/frontend/src/views/RequestAccessView.vue @@ -148,6 +148,10 @@ Verifying email… +