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 socket
|
||||||
import time
|
import time
|
||||||
|
from urllib.parse import quote
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from flask import jsonify, g, request
|
from flask import jsonify, g, request
|
||||||
|
|
||||||
from .. import settings
|
from .. import settings
|
||||||
|
from ..db import connect
|
||||||
from ..keycloak import admin_client, require_auth, require_account_access
|
from ..keycloak import admin_client, require_auth, require_account_access
|
||||||
from ..nextcloud_mail_sync import trigger as trigger_nextcloud_mail_sync
|
from ..nextcloud_mail_sync import trigger as trigger_nextcloud_mail_sync
|
||||||
from ..utils import random_password
|
from ..utils import random_password
|
||||||
@ -55,6 +57,7 @@ def register(app) -> None:
|
|||||||
jellyfin_sync_status = "unknown"
|
jellyfin_sync_status = "unknown"
|
||||||
jellyfin_sync_detail = ""
|
jellyfin_sync_detail = ""
|
||||||
jellyfin_user_is_ldap = False
|
jellyfin_user_is_ldap = False
|
||||||
|
onboarding_url = ""
|
||||||
|
|
||||||
if not admin_client().ready():
|
if not admin_client().ready():
|
||||||
mailu_status = "server not configured"
|
mailu_status = "server not configured"
|
||||||
@ -277,9 +280,30 @@ def register(app) -> None:
|
|||||||
if not vaultwarden_status:
|
if not vaultwarden_status:
|
||||||
vaultwarden_status = "needs provisioning"
|
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(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"user": {"username": username, "email": keycloak_email, "groups": g.keycloak_groups},
|
"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},
|
"mailu": {"status": mailu_status, "username": mailu_username, "app_password": mailu_app_password},
|
||||||
"nextcloud_mail": {
|
"nextcloud_mail": {
|
||||||
"status": nextcloud_mail_status,
|
"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_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_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_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: httpx.Client | None = None
|
||||||
_ADMIN_SESSION_EXPIRES_AT: float = 0.0
|
_ADMIN_SESSION_EXPIRES_AT: float = 0.0
|
||||||
_ADMIN_SESSION_BASE_URL: str = ""
|
_ADMIN_SESSION_BASE_URL: str = ""
|
||||||
|
_ADMIN_RATE_LIMITED_UNTIL: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
def _admin_session(base_url: str) -> httpx.Client:
|
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()
|
now = time.time()
|
||||||
with _ADMIN_LOCK:
|
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:
|
if _ADMIN_SESSION and now < _ADMIN_SESSION_EXPIRES_AT and _ADMIN_SESSION_BASE_URL == base_url:
|
||||||
return _ADMIN_SESSION
|
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.
|
# Vaultwarden can rate-limit admin login attempts, so keep this session cached briefly.
|
||||||
resp = client.post("/admin", data={"token": token})
|
resp = client.post("/admin", data={"token": token})
|
||||||
if resp.status_code == 429:
|
if resp.status_code == 429:
|
||||||
|
_ADMIN_RATE_LIMITED_UNTIL = now + float(settings.VAULTWARDEN_ADMIN_RATE_LIMIT_BACKOFF_SEC)
|
||||||
raise RuntimeError("vaultwarden rate limited")
|
raise RuntimeError("vaultwarden rate limited")
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|
||||||
@ -142,9 +146,12 @@ def _admin_session(base_url: str) -> httpx.Client:
|
|||||||
|
|
||||||
|
|
||||||
def invite_user(email: str) -> VaultwardenInvite:
|
def invite_user(email: str) -> VaultwardenInvite:
|
||||||
|
global _ADMIN_RATE_LIMITED_UNTIL
|
||||||
email = (email or "").strip()
|
email = (email or "").strip()
|
||||||
if not email or "@" not in email:
|
if not email or "@" not in email:
|
||||||
return VaultwardenInvite(ok=False, status="invalid_email", detail="email invalid")
|
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.
|
# 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}"
|
base_url = f"http://{settings.VAULTWARDEN_SERVICE_HOST}"
|
||||||
@ -163,6 +170,7 @@ def invite_user(email: str) -> VaultwardenInvite:
|
|||||||
session = _admin_session(candidate)
|
session = _admin_session(candidate)
|
||||||
resp = session.post("/admin/invite", json={"email": email})
|
resp = session.post("/admin/invite", json={"email": email})
|
||||||
if resp.status_code == 429:
|
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")
|
return VaultwardenInvite(ok=False, status="rate_limited", detail="vaultwarden rate limited")
|
||||||
|
|
||||||
if resp.status_code in {200, 201, 204}:
|
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}"
|
last_error = f"status {resp.status_code}"
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_error = str(exc)
|
last_error = str(exc)
|
||||||
|
if "rate limited" in last_error.lower():
|
||||||
|
return VaultwardenInvite(ok=False, status="rate_limited", detail="vaultwarden rate limited")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return VaultwardenInvite(ok=False, status="error", detail=last_error or "failed to invite")
|
return VaultwardenInvite(ok=False, status="error", detail=last_error or "failed to invite")
|
||||||
|
|
||||||
|
|||||||
@ -21,7 +21,7 @@
|
|||||||
Change password
|
Change password
|
||||||
</a>
|
</a>
|
||||||
<button v-else class="pill mono" type="button" @click="doLogin">Login</button>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -386,7 +386,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, reactive, watch } from "vue";
|
import { onMounted, reactive, ref, watch } from "vue";
|
||||||
import { auth, authFetch, login } from "@/auth";
|
import { auth, authFetch, login } from "@/auth";
|
||||||
|
|
||||||
const mailu = reactive({
|
const mailu = reactive({
|
||||||
@ -452,6 +452,7 @@ const admin = reactive({
|
|||||||
error: "",
|
error: "",
|
||||||
acting: {},
|
acting: {},
|
||||||
});
|
});
|
||||||
|
const onboardingUrl = ref("/onboarding");
|
||||||
|
|
||||||
const doLogin = () => login("/account");
|
const doLogin = () => login("/account");
|
||||||
|
|
||||||
@ -482,6 +483,7 @@ watch(
|
|||||||
vaultwarden.status = "login required";
|
vaultwarden.status = "login required";
|
||||||
wger.status = "login required";
|
wger.status = "login required";
|
||||||
firefly.status = "login required";
|
firefly.status = "login required";
|
||||||
|
onboardingUrl.value = "/onboarding";
|
||||||
admin.enabled = false;
|
admin.enabled = false;
|
||||||
admin.requests = [];
|
admin.requests = [];
|
||||||
return;
|
return;
|
||||||
@ -531,6 +533,7 @@ async function refreshOverview() {
|
|||||||
jellyfin.username = data.jellyfin?.username || auth.username;
|
jellyfin.username = data.jellyfin?.username || auth.username;
|
||||||
jellyfin.syncStatus = data.jellyfin?.sync_status || "";
|
jellyfin.syncStatus = data.jellyfin?.sync_status || "";
|
||||||
jellyfin.syncDetail = data.jellyfin?.sync_detail || "";
|
jellyfin.syncDetail = data.jellyfin?.sync_detail || "";
|
||||||
|
onboardingUrl.value = data.onboarding_url || "/onboarding";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
mailu.status = "unavailable";
|
mailu.status = "unavailable";
|
||||||
nextcloudMail.status = "unavailable";
|
nextcloudMail.status = "unavailable";
|
||||||
@ -540,6 +543,7 @@ async function refreshOverview() {
|
|||||||
jellyfin.status = "unavailable";
|
jellyfin.status = "unavailable";
|
||||||
jellyfin.syncStatus = "";
|
jellyfin.syncStatus = "";
|
||||||
jellyfin.syncDetail = "";
|
jellyfin.syncDetail = "";
|
||||||
|
onboardingUrl.value = "/onboarding";
|
||||||
const message = err?.message ? `Failed to load account status (${err.message})` : "Failed to load account status.";
|
const message = err?.message ? `Failed to load account status (${err.message})` : "Failed to load account status.";
|
||||||
mailu.error = message;
|
mailu.error = message;
|
||||||
nextcloudMail.error = message;
|
nextcloudMail.error = message;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user