Merge pull request 'fix: harden dashboard auth and RBAC security flows' (#33) from dev into main
Deploy Dashboard / deploy (push) Successful in 46s
Deploy Dashboard / deploy (push) Successful in 46s
Merge pull request #33 from dev
This commit was merged in pull request #33.
This commit is contained in:
+47
-21
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { login, setToken, setRefreshToken, get, post } from "../lib/api.js";
|
||||
import { login, setToken } from "../lib/api.js";
|
||||
import { fade, fly } from "svelte/transition";
|
||||
|
||||
let username = $state("");
|
||||
@@ -56,7 +56,6 @@
|
||||
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || "Passkey verification failed"); }
|
||||
const data = await res.json();
|
||||
setToken(data.access_token);
|
||||
setRefreshToken(data.refresh_token);
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
if (e.name === "NotAllowedError") { error = "Passkey authentication cancelled"; }
|
||||
@@ -79,7 +78,6 @@
|
||||
const res = await login(payload); // api.js helper sends JSON
|
||||
|
||||
setToken(res.access_token);
|
||||
setRefreshToken(res.refresh_token);
|
||||
// Determine if we need to reload or just update state.
|
||||
// Reload is safest to clear any stale state.
|
||||
window.location.reload();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { VoiceSession } from "../lib/voice.js";
|
||||
import { getWebSocketToken } from "../lib/api.js";
|
||||
import { tryRefreshSession } from "../lib/api.js";
|
||||
|
||||
const PERSISTENCE_STATUS_PREFIX = "__DASHBOARD_PERSISTENCE__:";
|
||||
const PERSISTENCE_STATUS_PREFIX_BYTES = new TextEncoder().encode(PERSISTENCE_STATUS_PREFIX);
|
||||
@@ -253,14 +253,16 @@
|
||||
}
|
||||
|
||||
async function connect(forceRefresh = false) {
|
||||
const token = await getWebSocketToken(forceRefresh);
|
||||
if (!token) {
|
||||
handleAuthExpired();
|
||||
return null;
|
||||
if (forceRefresh) {
|
||||
const refreshed = await tryRefreshSession();
|
||||
if (!refreshed) {
|
||||
handleAuthExpired();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const ws = new WebSocket(
|
||||
`${proto}//${location.host}/ws/terminal?host=${encodeURIComponent(tab.hostId)}&token=${encodeURIComponent(token)}`
|
||||
`${proto}//${location.host}/ws/terminal?host=${encodeURIComponent(tab.hostId)}`
|
||||
);
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
@@ -307,9 +309,9 @@
|
||||
if (e.code === 1008 && reason === "Unauthorized") {
|
||||
if (!closedManually && !authRecoveryInFlight) {
|
||||
authRecoveryInFlight = true;
|
||||
const recoveredToken = await getWebSocketToken(true);
|
||||
const refreshed = await tryRefreshSession();
|
||||
authRecoveryInFlight = false;
|
||||
if (recoveredToken) {
|
||||
if (refreshed) {
|
||||
reconnecting = true;
|
||||
updateTab(tabId, {
|
||||
connected: false,
|
||||
|
||||
Reference in New Issue
Block a user