portal: gate requests on verified email

This commit is contained in:
Brad Stein 2026-01-03 02:36:29 -03:00
parent 2c2f0b04d9
commit 13b0099c29
8 changed files with 345 additions and 37 deletions

View File

@ -33,6 +33,9 @@ def ensure_schema() -> None:
contact_email TEXT,
note TEXT,
status TEXT NOT NULL,
email_verification_token_hash TEXT,
email_verification_sent_at TIMESTAMPTZ,
email_verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
decided_at TIMESTAMPTZ,
decided_by TEXT,
@ -43,6 +46,9 @@ def ensure_schema() -> None:
)
conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS initial_password TEXT")
conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS initial_password_revealed_at TIMESTAMPTZ")
conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS email_verification_token_hash TEXT")
conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS email_verification_sent_at TIMESTAMPTZ")
conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS access_request_tasks (

View File

@ -0,0 +1,53 @@
from __future__ import annotations
import smtplib
from email.message import EmailMessage
from . import settings
class MailerError(RuntimeError):
pass
def send_text_email(*, to_addr: str, subject: str, body: str) -> None:
if not to_addr:
raise MailerError("missing recipient")
if not settings.SMTP_HOST:
raise MailerError("smtp not configured")
message = EmailMessage()
message["From"] = settings.SMTP_FROM
message["To"] = to_addr
message["Subject"] = subject
message.set_content(body)
smtp_cls = smtplib.SMTP_SSL if settings.SMTP_USE_TLS else smtplib.SMTP
try:
with smtp_cls(settings.SMTP_HOST, settings.SMTP_PORT, timeout=settings.SMTP_TIMEOUT_SEC) as client:
if settings.SMTP_STARTTLS and not settings.SMTP_USE_TLS:
client.starttls()
if settings.SMTP_USERNAME:
client.login(settings.SMTP_USERNAME, settings.SMTP_PASSWORD)
client.send_message(message)
except Exception as exc:
raise MailerError("failed to send email") from exc
def access_request_verification_body(*, request_code: str, verify_url: str) -> str:
return "\n".join(
[
"Atlas — confirm your email",
"",
"Someone requested an Atlas account using this email address.",
"",
f"Request code: {request_code}",
"",
"To confirm this request, open:",
verify_url,
"",
"If you did not request access, you can ignore this email.",
"",
]
)

View File

@ -12,6 +12,7 @@ from .utils import random_password
from .vaultwarden import invite_user
MAILU_EMAIL_ATTR = "mailu_email"
MAILU_APP_PASSWORD_ATTR = "mailu_app_password"
REQUIRED_PROVISION_TASKS: tuple[str, ...] = (
"keycloak_user",
@ -78,7 +79,12 @@ def provision_access_request(request_code: str) -> ProvisionResult:
with connect() as conn:
row = conn.execute(
"""
SELECT username, contact_email, status, initial_password, initial_password_revealed_at
SELECT username,
contact_email,
email_verified_at,
status,
initial_password,
initial_password_revealed_at
FROM access_requests
WHERE request_code = %s
""",
@ -89,6 +95,7 @@ def provision_access_request(request_code: str) -> ProvisionResult:
username = str(row.get("username") or "")
contact_email = str(row.get("contact_email") or "")
email_verified_at = row.get("email_verified_at")
status = str(row.get("status") or "")
initial_password = row.get("initial_password")
revealed_at = row.get("initial_password_revealed_at")
@ -97,24 +104,43 @@ def provision_access_request(request_code: str) -> ProvisionResult:
return ProvisionResult(ok=False, status=status or "unknown")
user_id = ""
mailu_email = f"{username}@{settings.MAILU_DOMAIN}"
# Task: ensure Keycloak user exists
try:
user = admin_client().find_user(username)
if not user:
email = contact_email.strip() or f"{username}@{settings.MAILU_DOMAIN}"
email = contact_email.strip()
if not email:
raise RuntimeError("contact email missing")
payload = {
"username": username,
"enabled": True,
"email": email,
"emailVerified": False,
"emailVerified": bool(email_verified_at),
"requiredActions": ["CONFIGURE_TOTP"],
"attributes": {MAILU_EMAIL_ATTR: [mailu_email]},
}
created_id = admin_client().create_user(payload)
user = admin_client().get_user(created_id)
user_id = str((user or {}).get("id") or "")
if not user_id:
raise RuntimeError("user id missing")
try:
full = admin_client().get_user(user_id)
attrs = full.get("attributes") or {}
if isinstance(attrs, dict):
raw_mailu = attrs.get(MAILU_EMAIL_ATTR)
if isinstance(raw_mailu, list) and raw_mailu and isinstance(raw_mailu[0], str):
mailu_email = raw_mailu[0]
elif isinstance(raw_mailu, str) and raw_mailu:
mailu_email = raw_mailu
if not mailu_email:
mailu_email = f"{username}@{settings.MAILU_DOMAIN}"
admin_client().set_user_attribute(username, MAILU_EMAIL_ATTR, mailu_email)
except Exception:
# Non-fatal: Mailu sync will fall back to username@domain.
mailu_email = f"{username}@{settings.MAILU_DOMAIN}"
_upsert_task(conn, request_code, "keycloak_user", "ok", None)
except Exception:
_upsert_task(conn, request_code, "keycloak_user", "error", "failed to ensure user")
@ -201,15 +227,7 @@ def provision_access_request(request_code: str) -> ProvisionResult:
# Task: ensure Vaultwarden account exists (invite flow)
try:
if user_id:
full = admin_client().get_user(user_id)
keycloak_email = str(full.get("email") or "")
email = ""
if keycloak_email.lower().endswith(f"@{settings.MAILU_DOMAIN.lower()}"):
email = keycloak_email
else:
email = f"{username}@{settings.MAILU_DOMAIN}"
result = invite_user(email)
result = invite_user(mailu_email or f"{username}@{settings.MAILU_DOMAIN}")
if result.ok:
_upsert_task(conn, request_code, "vaultwarden_invite", "ok", result.status)
else:

View File

@ -1,9 +1,13 @@
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 jsonify, request, g
@ -11,6 +15,7 @@ import psycopg
from ..db import connect, configured
from ..keycloak import admin_client, require_auth
from ..mailer import MailerError, access_request_verification_body, send_text_email
from ..rate_limit import rate_limit_allow
from ..provisioning import provision_tasks_complete
from .. import settings
@ -39,6 +44,18 @@ def _client_ip() -> str:
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}/request-access?code={quote(request_code)}&verify={quote(token)}"
ONBOARDING_STEPS: tuple[str, ...] = (
"keycloak_password_changed",
"keycloak_mfa_configured",
@ -207,8 +224,12 @@ def register(app) -> None:
return jsonify({"error": "username must be 3-32 characters"}), 400
if not re.fullmatch(r"[a-zA-Z0-9._-]+", username):
return jsonify({"error": "username contains invalid characters"}), 400
if email and "@" not in email:
if not email:
return jsonify({"error": "email is required"}), 400
if "@" not in email:
return jsonify({"error": "invalid email"}), 400
if email.lower().endswith(f"@{settings.MAILU_DOMAIN.lower()}"):
return jsonify({"error": "email must be an external address"}), 400
if admin_client().ready() and admin_client().find_user(username):
return jsonify({"error": "username already exists"}), 409
@ -219,25 +240,60 @@ def register(app) -> None:
"""
SELECT request_code, status
FROM access_requests
WHERE username = %s AND status = 'pending'
WHERE username = %s AND status IN (%s, 'pending')
ORDER BY created_at DESC
LIMIT 1
""",
(username,),
(username, EMAIL_VERIFY_PENDING_STATUS),
).fetchone()
if existing:
return jsonify({"ok": True, "request_code": existing["request_code"], "status": existing["status"]})
existing_status = str(existing.get("status") or "")
request_code = str(existing.get("request_code") or "")
if existing_status != EMAIL_VERIFY_PENDING_STATUS:
return jsonify({"ok": True, "request_code": request_code, "status": existing_status})
token = secrets.token_urlsafe(24)
token_hash = _hash_verification_token(token)
conn.execute(
"""
UPDATE access_requests
SET contact_email = %s,
note = %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, token_hash, request_code, EMAIL_VERIFY_PENDING_STATUS),
)
verify_url = _verify_url(request_code, token)
try:
send_text_email(
to_addr=email,
subject="Atlas: confirm your email",
body=access_request_verification_body(request_code=request_code, verify_url=verify_url),
)
except MailerError:
return (
jsonify({"error": "failed to send verification email", "request_code": request_code}),
502,
)
return jsonify({"ok": True, "request_code": request_code, "status": EMAIL_VERIFY_PENDING_STATUS})
request_code = _random_request_code(username)
token = secrets.token_urlsafe(24)
token_hash = _hash_verification_token(token)
try:
conn.execute(
"""
INSERT INTO access_requests
(request_code, username, contact_email, note, status)
(request_code, username, contact_email, note, status,
email_verification_token_hash, email_verification_sent_at)
VALUES
(%s, %s, %s, %s, 'pending')
(%s, %s, %s, %s, %s, %s, NOW())
""",
(request_code, username, email or None, note or None),
(request_code, username, email, note or None, EMAIL_VERIFY_PENDING_STATUS, token_hash),
)
except psycopg.errors.UniqueViolation:
conn.rollback()
@ -245,19 +301,109 @@ def register(app) -> None:
"""
SELECT request_code, status
FROM access_requests
WHERE username = %s AND status = 'pending'
WHERE username = %s AND status IN (%s, 'pending')
ORDER BY created_at DESC
LIMIT 1
""",
(username,),
(username, EMAIL_VERIFY_PENDING_STATUS),
).fetchone()
if not existing:
raise
return jsonify({"ok": True, "request_code": existing["request_code"], "status": existing["status"]})
verify_url = _verify_url(request_code, token)
try:
send_text_email(
to_addr=email,
subject="Atlas: confirm your email",
body=access_request_verification_body(request_code=request_code, verify_url=verify_url),
)
except 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})
return jsonify({"ok": True, "request_code": request_code, "status": EMAIL_VERIFY_PENDING_STATUS})
@app.route("/api/access/request/verify", methods=["POST"])
def request_access_verify() -> Any:
if not settings.ACCESS_REQUEST_ENABLED:
return jsonify({"error": "request access disabled"}), 503
if not configured():
return jsonify({"error": "server not configured"}), 503
ip = _client_ip()
if not 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()
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 rate_limit_allow(
f"{ip}:{code}",
key="access_request_verify_code",
limit=30,
window_sec=60,
):
return jsonify({"error": "rate limited"}), 429
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"})
except Exception:
return jsonify({"error": "failed to verify"}), 502
@app.route("/api/access/request/status", methods=["POST"])
def request_access_status() -> Any:

View File

@ -32,6 +32,7 @@ def register(app) -> None:
username = g.keycloak_username
keycloak_email = g.keycloak_email or ""
mailu_email = ""
mailu_app_password = ""
mailu_status = "ready"
jellyfin_status = "ready"
@ -54,6 +55,11 @@ def register(app) -> None:
attrs = user.get("attributes") if isinstance(user, dict) else None
if isinstance(attrs, dict):
raw_mailu = attrs.get("mailu_email")
if isinstance(raw_mailu, list) and raw_mailu:
mailu_email = str(raw_mailu[0])
elif isinstance(raw_mailu, str) and raw_mailu:
mailu_email = raw_mailu
raw_pw = attrs.get("mailu_app_password")
if isinstance(raw_pw, list) and raw_pw:
mailu_app_password = str(raw_pw[0])
@ -61,13 +67,20 @@ def register(app) -> None:
mailu_app_password = raw_pw
user_id = user.get("id") if isinstance(user, dict) else None
if user_id and (not keycloak_email or not mailu_app_password):
if user_id and (not keycloak_email or not mailu_email or not mailu_app_password):
full = admin_client().get_user(str(user_id))
if not keycloak_email:
keycloak_email = str(full.get("email") or "")
if not mailu_app_password:
attrs = full.get("attributes") or {}
if isinstance(attrs, dict):
attrs = full.get("attributes") or {}
if isinstance(attrs, dict):
if not mailu_email:
raw_mailu = attrs.get("mailu_email")
if isinstance(raw_mailu, list) and raw_mailu and isinstance(raw_mailu[0], str):
mailu_email = raw_mailu[0]
elif isinstance(raw_mailu, str) and raw_mailu:
mailu_email = raw_mailu
if not mailu_app_password:
raw_pw = attrs.get("mailu_app_password")
if isinstance(raw_pw, list) and raw_pw:
mailu_app_password = str(raw_pw[0])
@ -79,11 +92,7 @@ def register(app) -> None:
jellyfin_sync_status = "unknown"
jellyfin_sync_detail = "unavailable"
mailu_username = ""
if keycloak_email and keycloak_email.lower().endswith(f"@{settings.MAILU_DOMAIN.lower()}"):
mailu_username = keycloak_email
elif username:
mailu_username = f"{username}@{settings.MAILU_DOMAIN}"
mailu_username = mailu_email or (f"{username}@{settings.MAILU_DOMAIN}" if username else "")
if not mailu_app_password and mailu_status == "ready":
mailu_status = "needs app password"

View File

@ -71,6 +71,9 @@ ACCESS_REQUEST_SUBMIT_RATE_WINDOW_SEC = int(
)
ACCESS_REQUEST_STATUS_RATE_LIMIT = int(os.getenv("ACCESS_REQUEST_STATUS_RATE_LIMIT", "60"))
ACCESS_REQUEST_STATUS_RATE_WINDOW_SEC = int(os.getenv("ACCESS_REQUEST_STATUS_RATE_WINDOW_SEC", "60"))
ACCESS_REQUEST_EMAIL_VERIFY_TTL_SEC = int(os.getenv("ACCESS_REQUEST_EMAIL_VERIFY_TTL_SEC", str(24 * 60 * 60)))
PORTAL_PUBLIC_BASE_URL = os.getenv("PORTAL_PUBLIC_BASE_URL", "https://bstein.dev").rstrip("/")
MAILU_DOMAIN = os.getenv("MAILU_DOMAIN", "bstein.dev")
MAILU_SYNC_URL = os.getenv(
@ -78,6 +81,15 @@ MAILU_SYNC_URL = os.getenv(
"http://mailu-sync-listener.mailu-mailserver.svc.cluster.local:8080/events",
).rstrip("/")
SMTP_HOST = os.getenv("SMTP_HOST", "mailu-front.mailu-mailserver.svc.cluster.local").strip()
SMTP_PORT = int(os.getenv("SMTP_PORT", "25"))
SMTP_USERNAME = os.getenv("SMTP_USERNAME", "").strip()
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "").strip()
SMTP_STARTTLS = _env_bool("SMTP_STARTTLS", "false")
SMTP_USE_TLS = _env_bool("SMTP_USE_TLS", "false")
SMTP_FROM = os.getenv("SMTP_FROM", "").strip() or f"postmaster@{MAILU_DOMAIN}"
SMTP_TIMEOUT_SEC = float(os.getenv("SMTP_TIMEOUT_SEC", "10"))
JELLYFIN_SYNC_URL = os.getenv("JELLYFIN_SYNC_URL", "").rstrip("/")
JELLYFIN_LDAP_HOST = os.getenv("JELLYFIN_LDAP_HOST", "openldap.sso.svc.cluster.local").strip()
JELLYFIN_LDAP_PORT = int(os.getenv("JELLYFIN_LDAP_PORT", "389"))

View File

@ -30,6 +30,18 @@
</div>
</div>
<div v-if="status === 'pending_email_verification'" class="steps">
<h3>Confirm your email</h3>
<p class="muted">
Open the verification email from Atlas and click the link to confirm your address. After verification, an admin can approve your request.
</p>
<p class="muted">
If you did not receive an email, return to
<a href="/request-access">Request Access</a>
and submit again using a reachable external address.
</p>
</div>
<div v-if="status === 'pending'" class="steps">
<h3>Awaiting approval</h3>
<p class="muted">An Atlas admin has to approve this request before an account can be provisioned.</p>
@ -206,6 +218,7 @@ const copied = ref(false);
function statusLabel(value) {
const key = (value || "").trim();
if (key === "pending_email_verification") return "confirm email";
if (key === "pending") return "awaiting approval";
if (key === "accounts_building") return "accounts building";
if (key === "awaiting_onboarding") return "awaiting onboarding";
@ -216,6 +229,7 @@ function statusLabel(value) {
function statusPillClass(value) {
const key = (value || "").trim();
if (key === "pending_email_verification") return "pill-warn";
if (key === "pending") return "pill-wait";
if (key === "accounts_building") return "pill-warn";
if (key === "awaiting_onboarding") return "pill-ok";

View File

@ -19,7 +19,7 @@
</div>
<p class="muted">
This creates a pending request in Atlas. If approved, check your request code for an onboarding link to complete setup.
Requests require a verified external email so Keycloak can support account recovery. After verification, an admin can approve your account.
</p>
<form class="form" @submit.prevent="submit" v-if="!submitted">
@ -37,15 +37,17 @@
</label>
<label class="field">
<span class="label mono">Email (optional)</span>
<span class="label mono">Email</span>
<input
v-model="form.email"
class="input mono"
type="email"
autocomplete="email"
placeholder="you@example.com (optional)"
placeholder="you@example.com"
:disabled="submitting"
required
/>
<span class="hint mono">Must be an external address (not @{{ mailDomain }})</span>
</label>
<label class="field">
@ -70,7 +72,7 @@
<div v-else class="success-box">
<div class="mono">Request submitted.</div>
<div class="muted">
Save this request code. You can use it to check the status of your request.
Save this request code. Check your email for a verification link, then use the code to track status.
</div>
<div class="request-code-row">
<span class="label mono">Request Code</span>
@ -106,6 +108,10 @@
</button>
</div>
<div v-if="verifying" class="muted" style="margin-top: 10px;">
Verifying email
</div>
<div v-if="onboardingUrl" class="actions" style="margin-top: 12px;">
<a class="primary" :href="onboardingUrl">Continue onboarding</a>
</div>
@ -119,10 +125,14 @@
</template>
<script setup>
import { reactive, ref } from "vue";
import { onMounted, reactive, ref } from "vue";
import { useRoute } from "vue-router";
const route = useRoute();
function statusLabel(value) {
const key = (value || "").trim();
if (key === "pending_email_verification") return "confirm email";
if (key === "pending") return "awaiting approval";
if (key === "accounts_building") return "accounts building";
if (key === "awaiting_onboarding") return "awaiting onboarding";
@ -133,6 +143,7 @@ function statusLabel(value) {
function statusPillClass(value) {
const key = (value || "").trim();
if (key === "pending_email_verification") return "pill-warn";
if (key === "pending") return "pill-wait";
if (key === "accounts_building") return "pill-warn";
if (key === "awaiting_onboarding") return "pill-ok";
@ -152,6 +163,8 @@ const submitted = ref(false);
const error = ref("");
const requestCode = ref("");
const copied = ref(false);
const verifying = ref(false);
const mailDomain = import.meta.env?.VITE_MAILU_DOMAIN || "bstein.dev";
const statusForm = reactive({
request_code: "",
@ -180,7 +193,7 @@ async function submit() {
submitted.value = true;
requestCode.value = data.request_code || "";
statusForm.request_code = requestCode.value;
status.value = "pending";
status.value = data.status || "pending_email_verification";
} catch (err) {
error.value = err.message || "Failed to submit request";
} finally {
@ -244,6 +257,43 @@ async function checkStatus() {
checking.value = false;
}
}
async function verifyFromLink(code, token) {
verifying.value = true;
try {
const resp = await fetch("/api/access/request/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
cache: "no-store",
body: JSON.stringify({ request_code: code, token }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
status.value = data.status || status.value;
} finally {
verifying.value = false;
}
}
onMounted(async () => {
const code = typeof route.query.code === "string" ? route.query.code.trim() : "";
const token = typeof route.query.verify === "string" ? route.query.verify.trim() : "";
if (code) {
requestCode.value = code;
statusForm.request_code = code;
submitted.value = true;
}
if (code && token) {
try {
await verifyFromLink(code, token);
} catch (err) {
error.value = err?.message || "Failed to verify email";
}
}
if (code) {
await checkStatus();
}
});
</script>
<style scoped>