portal: harden email verification

This commit is contained in:
Brad Stein 2026-01-21 20:44:07 -03:00
parent 3958f96ef8
commit 4abf999aea
3 changed files with 121 additions and 45 deletions

View File

@ -9,7 +9,7 @@ import string
from typing import Any from typing import Any
from urllib.parse import quote from urllib.parse import quote
from flask import jsonify, request, g from flask import jsonify, request, g, redirect
import psycopg import psycopg
@ -81,7 +81,7 @@ def _hash_verification_token(token: str) -> str:
def _verify_url(request_code: str, token: str) -> str: def _verify_url(request_code: str, token: str) -> str:
base = settings.PORTAL_PUBLIC_BASE_URL.rstrip("/") 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: 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, ...] = ( ONBOARDING_STEPS: tuple[str, ...] = (
"vaultwarden_master_password", "vaultwarden_master_password",
"keycloak_password_rotated", "keycloak_password_rotated",
@ -561,52 +614,34 @@ def register(app) -> None:
try: try:
with connect() as conn: with connect() as conn:
row = conn.execute( status = _verify_request(conn, code, token)
""" return jsonify({"ok": True, "status": status})
SELECT status, email_verification_token_hash, email_verification_sent_at, email_verified_at except VerificationError as exc:
FROM access_requests return jsonify({"error": exc.message}), exc.status_code
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"})
except Exception: except Exception:
return jsonify({"error": "failed to verify"}), 502 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"]) @app.route("/api/access/request/resend", methods=["POST"])
def request_access_resend() -> Any: def request_access_resend() -> Any:
if not settings.ACCESS_REQUEST_ENABLED: if not settings.ACCESS_REQUEST_ENABLED:

View File

@ -179,3 +179,30 @@ class AccessRequestTests(TestCase):
self.assertEqual(data.get("status"), "pending_email_verification") self.assertEqual(data.get("status"), "pending_email_verification")
self.assertEqual(sent.get("request_code"), "alice~CODE123") self.assertEqual(sent.get("request_code"), "alice~CODE123")
self.assertEqual(sent.get("email"), "alice@example.com") 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", ""))

View File

@ -148,6 +148,10 @@
Verifying email Verifying email
</div> </div>
<div v-if="verifyMessage" class="hint mono" style="margin-top: 10px;">
{{ verifyMessage }}
</div>
<div v-if="status === 'pending_email_verification'" class="actions" style="margin-top: 10px;"> <div v-if="status === 'pending_email_verification'" class="actions" style="margin-top: 10px;">
<button class="pill mono" type="button" :disabled="resending" @click="resendVerification"> <button class="pill mono" type="button" :disabled="resending" @click="resendVerification">
{{ resending ? "Resending..." : "Resend verification email" }} {{ resending ? "Resending..." : "Resend verification email" }}
@ -257,6 +261,7 @@ const tasks = ref([]);
const blocked = ref(false); const blocked = ref(false);
const resending = ref(false); const resending = ref(false);
const resendMessage = ref(""); const resendMessage = ref("");
const verifyMessage = ref("");
function taskPillClass(status) { function taskPillClass(status) {
const key = (status || "").trim(); const key = (status || "").trim();
@ -484,6 +489,7 @@ async function verifyFromLink(code, token) {
const data = await resp.json().catch(() => ({})); const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`); if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
status.value = data.status || status.value; status.value = data.status || status.value;
verifyMessage.value = "Email confirmed.";
} finally { } finally {
verifying.value = false; verifying.value = false;
} }
@ -515,6 +521,8 @@ async function resendVerification() {
onMounted(async () => { onMounted(async () => {
const code = typeof route.query.code === "string" ? route.query.code.trim() : ""; const code = typeof route.query.code === "string" ? route.query.code.trim() : "";
const token = typeof route.query.verify === "string" ? route.query.verify.trim() : ""; const token = typeof route.query.verify === "string" ? route.query.verify.trim() : "";
const verified = typeof route.query.verified === "string" ? route.query.verified.trim() : "";
const verifyError = typeof route.query.verify_error === "string" ? route.query.verify_error.trim() : "";
if (code) { if (code) {
requestCode.value = code; requestCode.value = code;
statusForm.request_code = code; statusForm.request_code = code;
@ -530,6 +538,12 @@ onMounted(async () => {
if (code) { if (code) {
await checkStatus(); await checkStatus();
} }
if (verified) {
verifyMessage.value = "Email confirmed.";
}
if (verifyError) {
error.value = `Email verification failed: ${decodeURIComponent(verifyError)}`;
}
}); });
</script> </script>