From 13b0099c2934b471bef69f74668168fd287b6668 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sat, 3 Jan 2026 02:36:29 -0300 Subject: [PATCH] portal: gate requests on verified email --- backend/atlas_portal/db.py | 6 + backend/atlas_portal/mailer.py | 53 ++++++ backend/atlas_portal/provisioning.py | 42 +++-- .../atlas_portal/routes/access_requests.py | 166 ++++++++++++++++-- backend/atlas_portal/routes/account.py | 27 ++- backend/atlas_portal/settings.py | 12 ++ frontend/src/views/OnboardingView.vue | 14 ++ frontend/src/views/RequestAccessView.vue | 62 ++++++- 8 files changed, 345 insertions(+), 37 deletions(-) create mode 100644 backend/atlas_portal/mailer.py diff --git a/backend/atlas_portal/db.py b/backend/atlas_portal/db.py index b95661d..c8c3311 100644 --- a/backend/atlas_portal/db.py +++ b/backend/atlas_portal/db.py @@ -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 ( diff --git a/backend/atlas_portal/mailer.py b/backend/atlas_portal/mailer.py new file mode 100644 index 0000000..ac0a322 --- /dev/null +++ b/backend/atlas_portal/mailer.py @@ -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.", + "", + ] + ) + diff --git a/backend/atlas_portal/provisioning.py b/backend/atlas_portal/provisioning.py index 11b2a75..c44ac3c 100644 --- a/backend/atlas_portal/provisioning.py +++ b/backend/atlas_portal/provisioning.py @@ -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: diff --git a/backend/atlas_portal/routes/access_requests.py b/backend/atlas_portal/routes/access_requests.py index 144e1aa..ea88268 100644 --- a/backend/atlas_portal/routes/access_requests.py +++ b/backend/atlas_portal/routes/access_requests.py @@ -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: diff --git a/backend/atlas_portal/routes/account.py b/backend/atlas_portal/routes/account.py index be13ab4..a7bf8ef 100644 --- a/backend/atlas_portal/routes/account.py +++ b/backend/atlas_portal/routes/account.py @@ -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" diff --git a/backend/atlas_portal/settings.py b/backend/atlas_portal/settings.py index fbf6b9e..1262cc9 100644 --- a/backend/atlas_portal/settings.py +++ b/backend/atlas_portal/settings.py @@ -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")) diff --git a/frontend/src/views/OnboardingView.vue b/frontend/src/views/OnboardingView.vue index 3d691c8..ec21ca7 100644 --- a/frontend/src/views/OnboardingView.vue +++ b/frontend/src/views/OnboardingView.vue @@ -30,6 +30,18 @@ +
+

Confirm your email

+

+ Open the verification email from Atlas and click the link to confirm your address. After verification, an admin can approve your request. +

+

+ If you did not receive an email, return to + Request Access + and submit again using a reachable external address. +

+
+

Awaiting approval

An Atlas admin has to approve this request before an account can be provisioned.

@@ -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"; diff --git a/frontend/src/views/RequestAccessView.vue b/frontend/src/views/RequestAccessView.vue index f0a201e..a95e33b 100644 --- a/frontend/src/views/RequestAccessView.vue +++ b/frontend/src/views/RequestAccessView.vue @@ -19,7 +19,7 @@

- 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.

@@ -37,15 +37,17 @@