portal: add onboarding link and vaultwarden backoff

This commit is contained in:
Brad Stein 2026-01-18 02:50:17 -03:00
parent 33538cd99b
commit b955e591d3
4 changed files with 44 additions and 4 deletions

View File

@ -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,

View File

@ -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")
)

View File

@ -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")

View File

@ -21,7 +21,7 @@
Change password
</a>
<button v-else class="pill mono" type="button" @click="doLogin">Login</button>
<a class="pill mono" href="/onboarding">Onboarding</a>
<a class="pill mono" :href="onboardingUrl">Onboarding</a>
</div>
</section>
@ -386,7 +386,7 @@
</template>
<script setup>
import { onMounted, reactive, watch } from "vue";
import { onMounted, reactive, ref, watch } from "vue";
import { auth, authFetch, login } from "@/auth";
const mailu = reactive({
@ -452,6 +452,7 @@ const admin = reactive({
error: "",
acting: {},
});
const onboardingUrl = ref("/onboarding");
const doLogin = () => login("/account");
@ -482,6 +483,7 @@ watch(
vaultwarden.status = "login required";
wger.status = "login required";
firefly.status = "login required";
onboardingUrl.value = "/onboarding";
admin.enabled = false;
admin.requests = [];
return;
@ -531,6 +533,7 @@ async function refreshOverview() {
jellyfin.username = data.jellyfin?.username || auth.username;
jellyfin.syncStatus = data.jellyfin?.sync_status || "";
jellyfin.syncDetail = data.jellyfin?.sync_detail || "";
onboardingUrl.value = data.onboarding_url || "/onboarding";
} catch (err) {
mailu.status = "unavailable";
nextcloudMail.status = "unavailable";
@ -540,6 +543,7 @@ async function refreshOverview() {
jellyfin.status = "unavailable";
jellyfin.syncStatus = "";
jellyfin.syncDetail = "";
onboardingUrl.value = "/onboarding";
const message = err?.message ? `Failed to load account status (${err.message})` : "Failed to load account status.";
mailu.error = message;
nextcloudMail.error = message;