From 4201917e17a4a953dd43d636f8140ca11395cc70 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Mon, 9 Mar 2026 23:44:23 +0800 Subject: [PATCH] fix: harden dashboard RBAC and cookie auth flows Restrict Docker/Files writes to admins, move terminal websocket auth to cookie-first with temporary query fallback, and migrate refresh-token handling to httpOnly cookies for safer session persistence. Co-Authored-By: Claude Opus 4.6 (1M context) --- dashboard/backend/auth.py | 68 +++++++---- dashboard/backend/main.py | 8 +- dashboard/backend/routers/auth.py | 111 +++++++++++++----- dashboard/backend/routers/docker_router.py | 5 +- dashboard/backend/routers/files.py | 7 +- dashboard/backend/routers/passkey.py | 5 +- dashboard/backend/routers/terminal.py | 7 +- dashboard/frontend/src/App.svelte | 19 +-- dashboard/frontend/src/lib/api.js | 48 +++----- dashboard/frontend/src/routes/Login.svelte | 4 +- dashboard/frontend/src/routes/Terminal.svelte | 18 +-- 11 files changed, 185 insertions(+), 115 deletions(-) diff --git a/dashboard/backend/auth.py b/dashboard/backend/auth.py index 89aa6b2..edc1b04 100644 --- a/dashboard/backend/auth.py +++ b/dashboard/backend/auth.py @@ -6,7 +6,7 @@ import tempfile import jwt from passlib.context import CryptContext import pyotp -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, Response, status from fastapi.security import OAuth2PasswordBearer import config @@ -14,6 +14,12 @@ import config pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login") +ACCESS_COOKIE_NAME = "nas_access_token" +REFRESH_COOKIE_NAME = "nas_refresh_token" +COOKIE_SECURE = True +COOKIE_SAMESITE = "lax" +COOKIE_PATH = "/" + def verify_password(plain_password, hashed_password): return pwd_context.verify(plain_password, hashed_password) @@ -26,18 +32,49 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): to_encode.update({"exp": expire, "type": "access"}) return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM) + def create_refresh_token(data: dict): to_encode = data.copy() expire = datetime.now(timezone.utc) + timedelta(minutes=config.REFRESH_TOKEN_EXPIRE_MINUTES) to_encode.update({"exp": expire, "type": "refresh", "tv": _load_token_version()}) return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM) -async def get_current_user(token: str = Depends(oauth2_scheme)): + +def _cookie_max_age(minutes: int) -> int: + return max(60, int(minutes * 60)) + + +def set_auth_cookies(response: Response, access_token: str, refresh_token: str): + response.set_cookie( + key=ACCESS_COOKIE_NAME, + value=access_token, + httponly=True, + secure=COOKIE_SECURE, + samesite=COOKIE_SAMESITE, + path=COOKIE_PATH, + max_age=_cookie_max_age(config.ACCESS_TOKEN_EXPIRE_MINUTES), + ) + response.set_cookie( + key=REFRESH_COOKIE_NAME, + value=refresh_token, + httponly=True, + secure=COOKIE_SECURE, + samesite=COOKIE_SAMESITE, + path=COOKIE_PATH, + max_age=_cookie_max_age(config.REFRESH_TOKEN_EXPIRE_MINUTES), + ) + + +def clear_auth_cookies(response: Response): + response.delete_cookie(ACCESS_COOKIE_NAME, path=COOKIE_PATH) + response.delete_cookie(REFRESH_COOKIE_NAME, path=COOKIE_PATH) + +def user_from_access_token(token: str, include_auth_header: bool = True): from rbac import User, get_pages, get_sidebar_links credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, + headers={"WWW-Authenticate": "Bearer"} if include_auth_header else None, ) try: payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM]) @@ -49,29 +86,18 @@ async def get_current_user(token: str = Depends(oauth2_scheme)): raise credentials_exception except jwt.PyJWTError: raise credentials_exception + pages = get_pages(username, role) sidebar_links = get_sidebar_links(username, role) return User(username=username, role=role, pages=pages, sidebar_links=sidebar_links) + +async def get_current_user(token: str = Depends(oauth2_scheme)): + return user_from_access_token(token) + + async def get_current_user_ws(token: str): - from rbac import User, get_pages, get_sidebar_links - credentials_exception = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - ) - try: - payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM]) - if payload.get("type") != "access": - raise credentials_exception - username: str = payload.get("sub") - role: str = payload.get("role", "admin") - if username is None: - raise credentials_exception - except jwt.PyJWTError: - raise credentials_exception - pages = get_pages(username, role) - sidebar_links = get_sidebar_links(username, role) - return User(username=username, role=role, pages=pages, sidebar_links=sidebar_links) + return user_from_access_token(token, include_auth_header=False) # TOTP Persistence AUTH_FILE = config.VOLUME_ROOT + "/docker/nas-dashboard/auth.json" diff --git a/dashboard/backend/main.py b/dashboard/backend/main.py index 2a64a76..604e311 100644 --- a/dashboard/backend/main.py +++ b/dashboard/backend/main.py @@ -9,7 +9,7 @@ from slowapi.errors import RateLimitExceeded from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security, passkey, totp, litellm, cc_connect, info_engine import auth as auth_module import config -from rbac import require_page, require_write +from rbac import require_page import asyncio import httpx @@ -160,11 +160,11 @@ app.include_router(totp.router, prefix="/api/auth", tags=["totp"]) # Protected endpoints with page-level RBAC app.include_router(docker_router.router, prefix="/api/docker", - dependencies=[Depends(_inject_user), Depends(require_page("docker")), Depends(require_write())]) + dependencies=[Depends(_inject_user), Depends(require_page("docker"))]) app.include_router(gitea.router, prefix="/api/gitea", dependencies=[Depends(_inject_user), Depends(require_page("gitea"))]) app.include_router(files.router, prefix="/api/files", - dependencies=[Depends(_inject_user), Depends(require_page("files")), Depends(require_write())]) + dependencies=[Depends(_inject_user), Depends(require_page("files"))]) app.include_router(system.router, prefix="/api/system", dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))]) app.include_router(litellm.router, prefix="/api/litellm", @@ -178,7 +178,7 @@ app.include_router(info_engine.router, prefix="/api/info-engine", app.include_router(security.router, prefix="/api/security", dependencies=[Depends(_inject_user), Depends(require_page("security"))]) -# WebSocket endpoint (auth handled inside handler via Query param) +# WebSocket endpoint (auth handled inside handler via auth cookie; query token fallback temporary) app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint) app.mount("/", StaticFiles(directory="/app/static", html=True), name="static") diff --git a/dashboard/backend/routers/auth.py b/dashboard/backend/routers/auth.py index 72eebde..8a7cb52 100644 --- a/dashboard/backend/routers/auth.py +++ b/dashboard/backend/routers/auth.py @@ -2,7 +2,8 @@ from datetime import timedelta import asyncio import httpx -from fastapi import APIRouter, Depends, HTTPException, status, Request +from typing import Optional +from fastapi import APIRouter, Body, Depends, HTTPException, status, Request, Response from pydantic import BaseModel from slowapi import Limiter from slowapi.util import get_remote_address @@ -15,11 +16,13 @@ logger = logging.getLogger(__name__) router = APIRouter() limiter = Limiter(key_func=get_remote_address) + class LoginRequest(BaseModel): username: str password: str totp_code: str = "" + class Token(BaseModel): access_token: str refresh_token: str @@ -28,8 +31,46 @@ class Token(BaseModel): has_2fa: bool role: str = "admin" + class RefreshRequest(BaseModel): - refresh_token: str + refresh_token: str = "" + + +def _set_auth_response_tokens(response: Response, access_token: str, refresh_token: str): + auth.set_auth_cookies(response, access_token, refresh_token) + + +def _extract_refresh_token(request: Request, req: Optional[RefreshRequest] = None) -> str: + cookie_token = request.cookies.get(auth.REFRESH_COOKIE_NAME, "") + if cookie_token: + return cookie_token + if req and req.refresh_token: + return req.refresh_token + raise HTTPException(status_code=401, detail="Missing refresh token") + + +async def _refresh_tokens_from_value(refresh_token_value: str): + try: + payload = jwt.decode(refresh_token_value, config.SECRET_KEY, algorithms=[config.ALGORITHM]) + if payload.get("type") != "refresh": + raise HTTPException(status_code=401, detail="Invalid token type") + username = payload.get("sub") + role = payload.get("role", "admin") + if username is None: + raise HTTPException(status_code=401, detail="Invalid user") + if not auth.verify_token_version(payload.get("tv", -1)): + raise HTTPException(status_code=401, detail="Token revoked") + except jwt.PyJWTError: + raise HTTPException(status_code=401, detail="Invalid refresh token") + + auth.increment_token_version() + access_token = auth.create_access_token( + data={"sub": username, "role": role}, + expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES), + ) + refresh_token = auth.create_refresh_token(data={"sub": username, "role": role}) + return access_token, refresh_token + async def send_failed_login_alert(ip_address: str, username: str, reason: str): logger.info("Triggering Telegram alert for %s from %s: %s", username, ip_address, reason) @@ -41,7 +82,7 @@ async def send_failed_login_alert(ip_address: str, username: str, reason: str): client_options = {} if config.TELEGRAM_PROXY: client_options["proxy"] = config.TELEGRAM_PROXY - + async with httpx.AsyncClient(**client_options) as client: res = await client.post( f"https://api.telegram.org/bot{config.TELEGRAM_BOT_TOKEN}/sendMessage", @@ -52,9 +93,10 @@ async def send_failed_login_alert(ip_address: str, username: str, reason: str): except Exception as e: logger.warning("Failed to send Telegram alert: %s", e) + @router.post("/login", response_model=Token) @limiter.limit("5/minute") -async def login(creds: LoginRequest, request: Request): +async def login(creds: LoginRequest, request: Request, response: Response): client_ip = request.client.host # Verify username/password if creds.username != config.ADMIN_USER or not auth.verify_password(creds.password, auth.load_password_hash()): @@ -89,6 +131,7 @@ async def login(creds: LoginRequest, request: Request): expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES), ) refresh_token = auth.create_refresh_token(data={"sub": creds.username, "role": "admin"}) + _set_auth_response_tokens(response, access_token, refresh_token) return { "access_token": access_token, "refresh_token": refresh_token, @@ -98,8 +141,9 @@ async def login(creds: LoginRequest, request: Request): "role": "admin", } + @router.get("/proxy") -async def proxy_auth(request: Request): +async def proxy_auth(request: Request, response: Response): """Auto-login when Authelia has already authenticated the user via forward-auth. Caddy sets Remote-User and Remote-Groups headers after successful Authelia verification.""" from rbac import resolve_role @@ -125,6 +169,7 @@ async def proxy_auth(request: Request): expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES), ) refresh_token = auth.create_refresh_token(data={"sub": remote_user, "role": role}) + _set_auth_response_tokens(response, access_token, refresh_token) return { "access_token": access_token, "refresh_token": refresh_token, @@ -134,8 +179,9 @@ async def proxy_auth(request: Request): "role": role, } + @router.get("/me") -async def read_users_me(current_user = Depends(auth.get_current_user)): +async def read_users_me(current_user=Depends(auth.get_current_user)): totp_secret = auth.load_totp_secret() return { "username": current_user.username, @@ -145,36 +191,31 @@ async def read_users_me(current_user = Depends(auth.get_current_user)): "has_2fa": bool(totp_secret), } + @router.post("/refresh") @limiter.limit("10/minute") -async def refresh_token(req: RefreshRequest, request: Request): - try: - payload = jwt.decode(req.refresh_token, config.SECRET_KEY, algorithms=[config.ALGORITHM]) - if payload.get("type") != "refresh": - raise HTTPException(status_code=401, detail="Invalid token type") - username = payload.get("sub") - role = payload.get("role", "admin") - if username is None: - raise HTTPException(status_code=401, detail="Invalid user") - if not auth.verify_token_version(payload.get("tv", -1)): - raise HTTPException(status_code=401, detail="Token revoked") - except jwt.PyJWTError: - raise HTTPException(status_code=401, detail="Invalid refresh token") - auth.increment_token_version() - access_token = auth.create_access_token( - data={"sub": username, "role": role}, - expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES), - ) - refresh_token = auth.create_refresh_token(data={"sub": username, "role": role}) +async def refresh_token(request: Request, response: Response, req: Optional[RefreshRequest] = Body(default=None)): + refresh_token_value = _extract_refresh_token(request, req) + access_token, refresh_token = await _refresh_tokens_from_value(refresh_token_value) + _set_auth_response_tokens(response, access_token, refresh_token) return {"access_token": access_token, "refresh_token": refresh_token} + +@router.post("/logout") +async def logout(response: Response): + auth.increment_token_version() + auth.clear_auth_cookies(response) + return {"ok": True} + + class ChangePasswordRequest(BaseModel): current_password: str new_password: str + @router.post("/change-password") @limiter.limit("5/minute") -async def change_password(req: ChangePasswordRequest, request: Request, current_user = Depends(auth.get_current_user)): +async def change_password(req: ChangePasswordRequest, request: Request, current_user=Depends(auth.get_current_user)): if not auth.verify_password(req.current_password, auth.load_password_hash()): raise HTTPException(status_code=400, detail="Current password is incorrect") if len(req.new_password) < 8: @@ -182,16 +223,18 @@ async def change_password(req: ChangePasswordRequest, request: Request, current_ auth.save_password_hash(auth.get_password_hash(req.new_password)) return {"message": "Password changed successfully"} + # RBAC admin endpoints @router.get("/rbac/config") -async def rbac_config(current_user = Depends(auth.get_current_user)): +async def rbac_config(current_user=Depends(auth.get_current_user)): if current_user.role != "admin": raise HTTPException(status_code=403, detail="Admin only") from rbac import load_rbac return load_rbac() + @router.put("/rbac/overrides/{username}") -async def rbac_set_override(username: str, request: Request, current_user = Depends(auth.get_current_user)): +async def rbac_set_override(username: str, request: Request, current_user=Depends(auth.get_current_user)): if current_user.role != "admin": raise HTTPException(status_code=403, detail="Admin only") from rbac import update_rbac @@ -208,14 +251,16 @@ async def rbac_set_override(username: str, request: Request, current_user = Depe update_rbac(mutator) return {"ok": True} + @router.get("/preferences") -async def get_preferences(current_user = Depends(auth.get_current_user)): +async def get_preferences(current_user=Depends(auth.get_current_user)): from rbac import get_sidebar_order order = get_sidebar_order(current_user.username) return {"sidebar_order": order} + @router.put("/preferences") -async def save_preferences(request: Request, current_user = Depends(auth.get_current_user)): +async def save_preferences(request: Request, current_user=Depends(auth.get_current_user)): import traceback from rbac import save_sidebar_order try: @@ -232,8 +277,9 @@ async def save_preferences(request: Request, current_user = Depends(auth.get_cur traceback.print_exc() raise HTTPException(status_code=500, detail=str(e)) + @router.delete("/rbac/overrides/{username}") -async def rbac_delete_override(username: str, current_user = Depends(auth.get_current_user)): +async def rbac_delete_override(username: str, current_user=Depends(auth.get_current_user)): if current_user.role != "admin": raise HTTPException(status_code=403, detail="Admin only") from rbac import update_rbac @@ -244,8 +290,9 @@ async def rbac_delete_override(username: str, current_user = Depends(auth.get_cu update_rbac(mutator) return {"ok": True} + @router.put("/rbac/roles/{role}") -async def rbac_update_role(role: str, request: Request, current_user = Depends(auth.get_current_user)): +async def rbac_update_role(role: str, request: Request, current_user=Depends(auth.get_current_user)): if current_user.role != "admin": raise HTTPException(status_code=403, detail="Admin only") from rbac import update_rbac diff --git a/dashboard/backend/routers/docker_router.py b/dashboard/backend/routers/docker_router.py index 8ad35ef..45e475e 100644 --- a/dashboard/backend/routers/docker_router.py +++ b/dashboard/backend/routers/docker_router.py @@ -1,6 +1,7 @@ import docker -from fastapi import APIRouter, Query +from fastapi import APIRouter, Depends, Query from config import DOCKER_HOST +from rbac import require_admin router = APIRouter() client = docker.DockerClient(base_url=DOCKER_HOST) @@ -21,7 +22,7 @@ def list_containers(): ] -@router.post("/containers/{container_id}/{action}") +@router.post("/containers/{container_id}/{action}", dependencies=[Depends(require_admin())]) def container_action(container_id: str, action: str): if action not in ("start", "stop", "restart"): return {"error": "invalid action"} diff --git a/dashboard/backend/routers/files.py b/dashboard/backend/routers/files.py index fb57a45..89a2978 100644 --- a/dashboard/backend/routers/files.py +++ b/dashboard/backend/routers/files.py @@ -2,9 +2,10 @@ import os import re import shutil from pathlib import Path -from fastapi import APIRouter, UploadFile, File, HTTPException +from fastapi import APIRouter, Depends, UploadFile, File, HTTPException from fastapi.responses import FileResponse from config import VOLUME_ROOT +from rbac import require_admin router = APIRouter() BASE = Path(VOLUME_ROOT) @@ -79,7 +80,7 @@ def download(path: str): raise HTTPException(status_code=403, detail="Access denied") -@router.post("/upload") +@router.post("/upload", dependencies=[Depends(require_admin())]) async def upload(path: str, file: UploadFile = File(...)): try: safe_name = _sanitize_filename(file.filename) @@ -99,7 +100,7 @@ async def upload(path: str, file: UploadFile = File(...)): return {"ok": True} -@router.delete("/delete") +@router.delete("/delete", dependencies=[Depends(require_admin())]) def delete(path: str, recursive: bool = False): try: target = _safe_path(path) diff --git a/dashboard/backend/routers/passkey.py b/dashboard/backend/routers/passkey.py index 2dde6b6..15aefce 100644 --- a/dashboard/backend/routers/passkey.py +++ b/dashboard/backend/routers/passkey.py @@ -2,7 +2,7 @@ import json import time from datetime import timedelta -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.responses import JSONResponse from slowapi import Limiter from slowapi.util import get_remote_address @@ -112,7 +112,7 @@ async def passkey_login_options(request: Request): @router.post("/passkey/login/verify") @limiter.limit("10/minute") -async def passkey_login_verify(request: Request): +async def passkey_login_verify(request: Request, response: Response): """Verify passkey authentication and issue tokens.""" body = await request.json() challenge = _get_challenge(body.get("response", {}).get("clientDataJSON", "")) @@ -146,6 +146,7 @@ async def passkey_login_verify(request: Request): expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES), ) refresh_token = auth.create_refresh_token(data={"sub": username, "role": "admin"}) + auth.set_auth_cookies(response, access_token, refresh_token) return { "access_token": access_token, "refresh_token": refresh_token, diff --git a/dashboard/backend/routers/terminal.py b/dashboard/backend/routers/terminal.py index 180faa9..e6134bd 100644 --- a/dashboard/backend/routers/terminal.py +++ b/dashboard/backend/routers/terminal.py @@ -6,7 +6,7 @@ from collections import Counter from fastapi import WebSocket, Query import asyncssh import config -from auth import get_current_user_ws +import auth logger = logging.getLogger(__name__) @@ -95,12 +95,13 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str await websocket.close(code=1008, reason="Unknown host") return - if not token: + access_token = websocket.cookies.get(auth.ACCESS_COOKIE_NAME) or token + if not access_token: await websocket.close(code=1008, reason="Unauthorized") return try: - user = await get_current_user_ws(token) + user = await auth.get_current_user_ws(access_token) except Exception: await websocket.close(code=1008, reason="Unauthorized") return diff --git a/dashboard/frontend/src/App.svelte b/dashboard/frontend/src/App.svelte index c877037..68c18d0 100644 --- a/dashboard/frontend/src/App.svelte +++ b/dashboard/frontend/src/App.svelte @@ -14,7 +14,7 @@ import InfoEngine from "./routes/InfoEngine.svelte"; import Login from "./routes/Login.svelte"; import { onMount } from "svelte"; - import { getToken, checkAuth, setToken, setRefreshToken, currentUser, setCurrentUser, getPreferences, savePreferences } from "./lib/api.js"; + import { getToken, checkAuth, setToken, currentUser, setCurrentUser, getPreferences, savePreferences, tryRefreshSession, logout as logoutSession } from "./lib/api.js"; const KNOWN_PAGES = new Set([ "dashboard", @@ -106,11 +106,10 @@ // Try proxy auth first (Authelia forward-auth sets Remote-User header) try { - const proxyRes = await fetch("/api/auth/proxy"); + const proxyRes = await fetch("/api/auth/proxy", { credentials: "same-origin" }); if (proxyRes.ok) { const data = await proxyRes.json(); setToken(data.access_token); - setRefreshToken(data.refresh_token); authorized = true; await fetchUserInfo(); const requestedPage = getRequestedPage(); @@ -123,11 +122,13 @@ } // Regular token auth check - const token = getToken(); - if (!token) { + if (!getToken()) { + const refreshed = await tryRefreshSession(); + if (!refreshed) { loading = false; authorized = false; return; + } } try { @@ -160,9 +161,13 @@ } } - function logout() { + async function logout() { + try { + await logoutSession(); + } catch (e) { + console.error("Logout failed:", e); + } setToken(""); - setRefreshToken(""); window.location.reload(); } diff --git a/dashboard/frontend/src/lib/api.js b/dashboard/frontend/src/lib/api.js index fa636ec..de188bc 100644 --- a/dashboard/frontend/src/lib/api.js +++ b/dashboard/frontend/src/lib/api.js @@ -1,5 +1,5 @@ const BASE = "/api"; -let token = localStorage.getItem("token") || ""; +let token = ""; // Current user info populated after auth export let currentUser = { username: "", role: "", pages: [] }; @@ -18,33 +18,24 @@ export function getToken() { return token; } -export function setRefreshToken(t) { - if (t) localStorage.setItem("refresh_token", t); - else localStorage.removeItem("refresh_token"); -} - -function getRefreshToken() { - return localStorage.getItem("refresh_token") || ""; -} +// Refresh token is cookie-managed server-side. +export function setRefreshToken() {} let refreshPromise = null; -async function tryRefresh() { +export async function tryRefreshSession() { if (refreshPromise) return refreshPromise; - const rt = getRefreshToken(); - if (!rt) return false; refreshPromise = (async () => { try { const r = await fetch(BASE + "/auth/refresh", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refresh_token: rt }), + credentials: "same-origin", }); if (!r.ok) return false; const data = await r.json(); - setToken(data.access_token); - setRefreshToken(data.refresh_token); - return true; + setToken(data.access_token || ""); + return !!data.access_token; } catch { return false; } finally { @@ -54,15 +45,6 @@ async function tryRefresh() { return refreshPromise; } -export async function getWebSocketToken(forceRefresh = false) { - if (!forceRefresh && token) return token; - const refreshed = await tryRefresh(); - if (refreshed && token) return token; - setToken(""); - setRefreshToken(""); - return ""; -} - async function request(path, opts = {}) { const headers = opts.headers || {}; if (token) headers["Authorization"] = `Bearer ${token}`; @@ -73,27 +55,29 @@ async function request(path, opts = {}) { delete opts.json; } + const fetchOpts = { ...opts, headers }; + if (path.startsWith("/auth/")) fetchOpts.credentials = "same-origin"; + try { - let r = await fetch(BASE + path, { ...opts, headers }); + let r = await fetch(BASE + path, fetchOpts); if (r.status === 401 && !path.includes("/auth/refresh")) { - const refreshed = await tryRefresh(); + const refreshed = await tryRefreshSession(); if (refreshed) { headers["Authorization"] = `Bearer ${token}`; - r = await fetch(BASE + path, { ...opts, headers }); + r = await fetch(BASE + path, fetchOpts); } } if (r.status === 401) { setToken(""); - setRefreshToken(""); throw new Error("Unauthorized"); } if (!r.ok) { const error = new Error(`HTTP ${r.status}`); error.status = r.status; - try { error.body = await r.json(); } catch { } + try { error.body = await r.json(); } catch {} throw error; } return r.json(); @@ -140,6 +124,10 @@ export function checkAuth() { return request("/auth/me"); } +export function logout() { + return request("/auth/logout", { method: "POST" }); +} + export function getPreferences() { return get("/auth/preferences"); } diff --git a/dashboard/frontend/src/routes/Login.svelte b/dashboard/frontend/src/routes/Login.svelte index 98ccf11..931159b 100644 --- a/dashboard/frontend/src/routes/Login.svelte +++ b/dashboard/frontend/src/routes/Login.svelte @@ -1,5 +1,5 @@