portal: harden email verification #10
@ -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:
|
||||||
|
|||||||
@ -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", ""))
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user