From b955e591d352a033639f8db4e3513599e717fabb Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sun, 18 Jan 2026 02:50:17 -0300 Subject: [PATCH] portal: add onboarding link and vaultwarden backoff --- backend/atlas_portal/routes/account.py | 24 ++++++++++++++++++++++++ backend/atlas_portal/settings.py | 3 +++ backend/atlas_portal/vaultwarden.py | 13 +++++++++++-- frontend/src/views/AccountView.vue | 8 ++++++-- 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/backend/atlas_portal/routes/account.py b/backend/atlas_portal/routes/account.py index 98a80a8..b178afc 100644 --- a/backend/atlas_portal/routes/account.py +++ b/backend/atlas_portal/routes/account.py @@ -2,12 +2,14 @@ from __future__ import annotations import socket import time +from urllib.parse import quote from typing import Any import httpx from flask import jsonify, g, request from .. import settings +from ..db import connect from ..keycloak import admin_client, require_auth, require_account_access from ..nextcloud_mail_sync import trigger as trigger_nextcloud_mail_sync from ..utils import random_password @@ -55,6 +57,7 @@ def register(app) -> None: jellyfin_sync_status = "unknown" jellyfin_sync_detail = "" jellyfin_user_is_ldap = False + onboarding_url = "" if not admin_client().ready(): mailu_status = "server not configured" @@ -277,9 +280,30 @@ def register(app) -> None: if not vaultwarden_status: vaultwarden_status = "needs provisioning" + if settings.PORTAL_DATABASE_URL and username: + request_code = "" + try: + with connect() as conn: + row = conn.execute( + "SELECT request_code FROM access_requests WHERE username = %s ORDER BY created_at DESC LIMIT 1", + (username,), + ).fetchone() + if not row and keycloak_email: + row = conn.execute( + "SELECT request_code FROM access_requests WHERE contact_email = %s ORDER BY created_at DESC LIMIT 1", + (keycloak_email,), + ).fetchone() + if row and isinstance(row, dict): + request_code = str(row.get("request_code") or "").strip() + except Exception: + request_code = "" + if request_code: + onboarding_url = f"{settings.PORTAL_PUBLIC_BASE_URL}/onboarding?code={quote(request_code)}" + return jsonify( { "user": {"username": username, "email": keycloak_email, "groups": g.keycloak_groups}, + "onboarding_url": onboarding_url, "mailu": {"status": mailu_status, "username": mailu_username, "app_password": mailu_app_password}, "nextcloud_mail": { "status": nextcloud_mail_status, diff --git a/backend/atlas_portal/settings.py b/backend/atlas_portal/settings.py index ec426de..0aaffe8 100644 --- a/backend/atlas_portal/settings.py +++ b/backend/atlas_portal/settings.py @@ -123,3 +123,6 @@ VAULTWARDEN_SERVICE_HOST = os.getenv("VAULTWARDEN_SERVICE_HOST", "vaultwarden-se VAULTWARDEN_ADMIN_SECRET_NAME = os.getenv("VAULTWARDEN_ADMIN_SECRET_NAME", "vaultwarden-admin").strip() VAULTWARDEN_ADMIN_SECRET_KEY = os.getenv("VAULTWARDEN_ADMIN_SECRET_KEY", "ADMIN_TOKEN").strip() VAULTWARDEN_ADMIN_SESSION_TTL_SEC = float(os.getenv("VAULTWARDEN_ADMIN_SESSION_TTL_SEC", "300")) +VAULTWARDEN_ADMIN_RATE_LIMIT_BACKOFF_SEC = float( + os.getenv("VAULTWARDEN_ADMIN_RATE_LIMIT_BACKOFF_SEC", "600") +) diff --git a/backend/atlas_portal/vaultwarden.py b/backend/atlas_portal/vaultwarden.py index 55c7773..0a774a3 100644 --- a/backend/atlas_portal/vaultwarden.py +++ b/backend/atlas_portal/vaultwarden.py @@ -99,12 +99,15 @@ _ADMIN_LOCK = threading.Lock() _ADMIN_SESSION: httpx.Client | None = None _ADMIN_SESSION_EXPIRES_AT: float = 0.0 _ADMIN_SESSION_BASE_URL: str = "" +_ADMIN_RATE_LIMITED_UNTIL: float = 0.0 def _admin_session(base_url: str) -> httpx.Client: - global _ADMIN_SESSION, _ADMIN_SESSION_EXPIRES_AT, _ADMIN_SESSION_BASE_URL + global _ADMIN_SESSION, _ADMIN_SESSION_EXPIRES_AT, _ADMIN_SESSION_BASE_URL, _ADMIN_RATE_LIMITED_UNTIL now = time.time() with _ADMIN_LOCK: + if _ADMIN_RATE_LIMITED_UNTIL and now < _ADMIN_RATE_LIMITED_UNTIL: + raise RuntimeError("vaultwarden rate limited") if _ADMIN_SESSION and now < _ADMIN_SESSION_EXPIRES_AT and _ADMIN_SESSION_BASE_URL == base_url: return _ADMIN_SESSION @@ -132,6 +135,7 @@ def _admin_session(base_url: str) -> httpx.Client: # Vaultwarden can rate-limit admin login attempts, so keep this session cached briefly. resp = client.post("/admin", data={"token": token}) if resp.status_code == 429: + _ADMIN_RATE_LIMITED_UNTIL = now + float(settings.VAULTWARDEN_ADMIN_RATE_LIMIT_BACKOFF_SEC) raise RuntimeError("vaultwarden rate limited") resp.raise_for_status() @@ -142,9 +146,12 @@ def _admin_session(base_url: str) -> httpx.Client: def invite_user(email: str) -> VaultwardenInvite: + global _ADMIN_RATE_LIMITED_UNTIL email = (email or "").strip() if not email or "@" not in email: return VaultwardenInvite(ok=False, status="invalid_email", detail="email invalid") + if _ADMIN_RATE_LIMITED_UNTIL and time.time() < _ADMIN_RATE_LIMITED_UNTIL: + return VaultwardenInvite(ok=False, status="rate_limited", detail="vaultwarden rate limited") # Prefer the service name when it works; fall back to pod IP because the Service can be misconfigured. base_url = f"http://{settings.VAULTWARDEN_SERVICE_HOST}" @@ -163,6 +170,7 @@ def invite_user(email: str) -> VaultwardenInvite: session = _admin_session(candidate) resp = session.post("/admin/invite", json={"email": email}) if resp.status_code == 429: + _ADMIN_RATE_LIMITED_UNTIL = time.time() + float(settings.VAULTWARDEN_ADMIN_RATE_LIMIT_BACKOFF_SEC) return VaultwardenInvite(ok=False, status="rate_limited", detail="vaultwarden rate limited") if resp.status_code in {200, 201, 204}: @@ -188,7 +196,8 @@ def invite_user(email: str) -> VaultwardenInvite: last_error = f"status {resp.status_code}" except Exception as exc: last_error = str(exc) + if "rate limited" in last_error.lower(): + return VaultwardenInvite(ok=False, status="rate_limited", detail="vaultwarden rate limited") continue return VaultwardenInvite(ok=False, status="error", detail=last_error or "failed to invite") - diff --git a/frontend/src/views/AccountView.vue b/frontend/src/views/AccountView.vue index fa44cfc..f9fe89f 100644 --- a/frontend/src/views/AccountView.vue +++ b/frontend/src/views/AccountView.vue @@ -21,7 +21,7 @@ Change password - Onboarding + Onboarding @@ -386,7 +386,7 @@