portal: add onboarding link and vaultwarden backoff
This commit is contained in:
parent
33538cd99b
commit
b955e591d3
@ -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,
|
||||
|
||||
@ -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")
|
||||
)
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user