fix: harden dashboard auth and RBAC security flows #33

Merged
jimmy merged 1 commits from dev into main 2026-03-09 23:53:52 +08:00
11 changed files with 185 additions and 115 deletions
+47 -21
View File
@@ -6,7 +6,7 @@ import tempfile
import jwt import jwt
from passlib.context import CryptContext from passlib.context import CryptContext
import pyotp import pyotp
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, Response, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
import config import config
@@ -14,6 +14,12 @@ import config
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login") 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): def verify_password(plain_password, hashed_password):
return pwd_context.verify(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"}) to_encode.update({"exp": expire, "type": "access"})
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM) return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
def create_refresh_token(data: dict): def create_refresh_token(data: dict):
to_encode = data.copy() to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(minutes=config.REFRESH_TOKEN_EXPIRE_MINUTES) expire = datetime.now(timezone.utc) + timedelta(minutes=config.REFRESH_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire, "type": "refresh", "tv": _load_token_version()}) to_encode.update({"exp": expire, "type": "refresh", "tv": _load_token_version()})
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM) 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 from rbac import User, get_pages, get_sidebar_links
credentials_exception = HTTPException( credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials", detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"} if include_auth_header else None,
) )
try: try:
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM]) 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 raise credentials_exception
except jwt.PyJWTError: except jwt.PyJWTError:
raise credentials_exception raise credentials_exception
pages = get_pages(username, role) pages = get_pages(username, role)
sidebar_links = get_sidebar_links(username, role) sidebar_links = get_sidebar_links(username, role)
return User(username=username, role=role, pages=pages, sidebar_links=sidebar_links) 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): async def get_current_user_ws(token: str):
from rbac import User, get_pages, get_sidebar_links return user_from_access_token(token, include_auth_header=False)
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)
# TOTP Persistence # TOTP Persistence
AUTH_FILE = config.VOLUME_ROOT + "/docker/nas-dashboard/auth.json" AUTH_FILE = config.VOLUME_ROOT + "/docker/nas-dashboard/auth.json"
+4 -4
View File
@@ -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 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 auth as auth_module
import config import config
from rbac import require_page, require_write from rbac import require_page
import asyncio import asyncio
import httpx import httpx
@@ -160,11 +160,11 @@ app.include_router(totp.router, prefix="/api/auth", tags=["totp"])
# Protected endpoints with page-level RBAC # Protected endpoints with page-level RBAC
app.include_router(docker_router.router, prefix="/api/docker", 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", app.include_router(gitea.router, prefix="/api/gitea",
dependencies=[Depends(_inject_user), Depends(require_page("gitea"))]) dependencies=[Depends(_inject_user), Depends(require_page("gitea"))])
app.include_router(files.router, prefix="/api/files", 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", app.include_router(system.router, prefix="/api/system",
dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))]) dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))])
app.include_router(litellm.router, prefix="/api/litellm", 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", app.include_router(security.router, prefix="/api/security",
dependencies=[Depends(_inject_user), Depends(require_page("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.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint)
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static") app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")
+79 -32
View File
@@ -2,7 +2,8 @@
from datetime import timedelta from datetime import timedelta
import asyncio import asyncio
import httpx 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 pydantic import BaseModel
from slowapi import Limiter from slowapi import Limiter
from slowapi.util import get_remote_address from slowapi.util import get_remote_address
@@ -15,11 +16,13 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
limiter = Limiter(key_func=get_remote_address) limiter = Limiter(key_func=get_remote_address)
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
username: str username: str
password: str password: str
totp_code: str = "" totp_code: str = ""
class Token(BaseModel): class Token(BaseModel):
access_token: str access_token: str
refresh_token: str refresh_token: str
@@ -28,8 +31,46 @@ class Token(BaseModel):
has_2fa: bool has_2fa: bool
role: str = "admin" role: str = "admin"
class RefreshRequest(BaseModel): 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): 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) 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 = {} client_options = {}
if config.TELEGRAM_PROXY: if config.TELEGRAM_PROXY:
client_options["proxy"] = config.TELEGRAM_PROXY client_options["proxy"] = config.TELEGRAM_PROXY
async with httpx.AsyncClient(**client_options) as client: async with httpx.AsyncClient(**client_options) as client:
res = await client.post( res = await client.post(
f"https://api.telegram.org/bot{config.TELEGRAM_BOT_TOKEN}/sendMessage", 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: except Exception as e:
logger.warning("Failed to send Telegram alert: %s", e) logger.warning("Failed to send Telegram alert: %s", e)
@router.post("/login", response_model=Token) @router.post("/login", response_model=Token)
@limiter.limit("5/minute") @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 client_ip = request.client.host
# Verify username/password # Verify username/password
if creds.username != config.ADMIN_USER or not auth.verify_password(creds.password, auth.load_password_hash()): 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), expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
) )
refresh_token = auth.create_refresh_token(data={"sub": creds.username, "role": "admin"}) refresh_token = auth.create_refresh_token(data={"sub": creds.username, "role": "admin"})
_set_auth_response_tokens(response, access_token, refresh_token)
return { return {
"access_token": access_token, "access_token": access_token,
"refresh_token": refresh_token, "refresh_token": refresh_token,
@@ -98,8 +141,9 @@ async def login(creds: LoginRequest, request: Request):
"role": "admin", "role": "admin",
} }
@router.get("/proxy") @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. """Auto-login when Authelia has already authenticated the user via forward-auth.
Caddy sets Remote-User and Remote-Groups headers after successful Authelia verification.""" Caddy sets Remote-User and Remote-Groups headers after successful Authelia verification."""
from rbac import resolve_role from rbac import resolve_role
@@ -125,6 +169,7 @@ async def proxy_auth(request: Request):
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES), expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
) )
refresh_token = auth.create_refresh_token(data={"sub": remote_user, "role": role}) refresh_token = auth.create_refresh_token(data={"sub": remote_user, "role": role})
_set_auth_response_tokens(response, access_token, refresh_token)
return { return {
"access_token": access_token, "access_token": access_token,
"refresh_token": refresh_token, "refresh_token": refresh_token,
@@ -134,8 +179,9 @@ async def proxy_auth(request: Request):
"role": role, "role": role,
} }
@router.get("/me") @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() totp_secret = auth.load_totp_secret()
return { return {
"username": current_user.username, "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), "has_2fa": bool(totp_secret),
} }
@router.post("/refresh") @router.post("/refresh")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def refresh_token(req: RefreshRequest, request: Request): async def refresh_token(request: Request, response: Response, req: Optional[RefreshRequest] = Body(default=None)):
try: refresh_token_value = _extract_refresh_token(request, req)
payload = jwt.decode(req.refresh_token, config.SECRET_KEY, algorithms=[config.ALGORITHM]) access_token, refresh_token = await _refresh_tokens_from_value(refresh_token_value)
if payload.get("type") != "refresh": _set_auth_response_tokens(response, access_token, refresh_token)
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": access_token, "refresh_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): class ChangePasswordRequest(BaseModel):
current_password: str current_password: str
new_password: str new_password: str
@router.post("/change-password") @router.post("/change-password")
@limiter.limit("5/minute") @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()): if not auth.verify_password(req.current_password, auth.load_password_hash()):
raise HTTPException(status_code=400, detail="Current password is incorrect") raise HTTPException(status_code=400, detail="Current password is incorrect")
if len(req.new_password) < 8: 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)) auth.save_password_hash(auth.get_password_hash(req.new_password))
return {"message": "Password changed successfully"} return {"message": "Password changed successfully"}
# RBAC admin endpoints # RBAC admin endpoints
@router.get("/rbac/config") @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": if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only") raise HTTPException(status_code=403, detail="Admin only")
from rbac import load_rbac from rbac import load_rbac
return load_rbac() return load_rbac()
@router.put("/rbac/overrides/{username}") @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": if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only") raise HTTPException(status_code=403, detail="Admin only")
from rbac import update_rbac from rbac import update_rbac
@@ -208,14 +251,16 @@ async def rbac_set_override(username: str, request: Request, current_user = Depe
update_rbac(mutator) update_rbac(mutator)
return {"ok": True} return {"ok": True}
@router.get("/preferences") @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 from rbac import get_sidebar_order
order = get_sidebar_order(current_user.username) order = get_sidebar_order(current_user.username)
return {"sidebar_order": order} return {"sidebar_order": order}
@router.put("/preferences") @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 import traceback
from rbac import save_sidebar_order from rbac import save_sidebar_order
try: try:
@@ -232,8 +277,9 @@ async def save_preferences(request: Request, current_user = Depends(auth.get_cur
traceback.print_exc() traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.delete("/rbac/overrides/{username}") @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": if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only") raise HTTPException(status_code=403, detail="Admin only")
from rbac import update_rbac 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) update_rbac(mutator)
return {"ok": True} return {"ok": True}
@router.put("/rbac/roles/{role}") @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": if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only") raise HTTPException(status_code=403, detail="Admin only")
from rbac import update_rbac from rbac import update_rbac
+3 -2
View File
@@ -1,6 +1,7 @@
import docker import docker
from fastapi import APIRouter, Query from fastapi import APIRouter, Depends, Query
from config import DOCKER_HOST from config import DOCKER_HOST
from rbac import require_admin
router = APIRouter() router = APIRouter()
client = docker.DockerClient(base_url=DOCKER_HOST) 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): def container_action(container_id: str, action: str):
if action not in ("start", "stop", "restart"): if action not in ("start", "stop", "restart"):
return {"error": "invalid action"} return {"error": "invalid action"}
+4 -3
View File
@@ -2,9 +2,10 @@ import os
import re import re
import shutil import shutil
from pathlib import Path 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 fastapi.responses import FileResponse
from config import VOLUME_ROOT from config import VOLUME_ROOT
from rbac import require_admin
router = APIRouter() router = APIRouter()
BASE = Path(VOLUME_ROOT) BASE = Path(VOLUME_ROOT)
@@ -79,7 +80,7 @@ def download(path: str):
raise HTTPException(status_code=403, detail="Access denied") 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(...)): async def upload(path: str, file: UploadFile = File(...)):
try: try:
safe_name = _sanitize_filename(file.filename) safe_name = _sanitize_filename(file.filename)
@@ -99,7 +100,7 @@ async def upload(path: str, file: UploadFile = File(...)):
return {"ok": True} return {"ok": True}
@router.delete("/delete") @router.delete("/delete", dependencies=[Depends(require_admin())])
def delete(path: str, recursive: bool = False): def delete(path: str, recursive: bool = False):
try: try:
target = _safe_path(path) target = _safe_path(path)
+3 -2
View File
@@ -2,7 +2,7 @@
import json import json
import time import time
from datetime import timedelta 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 fastapi.responses import JSONResponse
from slowapi import Limiter from slowapi import Limiter
from slowapi.util import get_remote_address from slowapi.util import get_remote_address
@@ -112,7 +112,7 @@ async def passkey_login_options(request: Request):
@router.post("/passkey/login/verify") @router.post("/passkey/login/verify")
@limiter.limit("10/minute") @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.""" """Verify passkey authentication and issue tokens."""
body = await request.json() body = await request.json()
challenge = _get_challenge(body.get("response", {}).get("clientDataJSON", "")) 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), expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
) )
refresh_token = auth.create_refresh_token(data={"sub": username, "role": "admin"}) refresh_token = auth.create_refresh_token(data={"sub": username, "role": "admin"})
auth.set_auth_cookies(response, access_token, refresh_token)
return { return {
"access_token": access_token, "access_token": access_token,
"refresh_token": refresh_token, "refresh_token": refresh_token,
+4 -3
View File
@@ -6,7 +6,7 @@ from collections import Counter
from fastapi import WebSocket, Query from fastapi import WebSocket, Query
import asyncssh import asyncssh
import config import config
from auth import get_current_user_ws import auth
logger = logging.getLogger(__name__) 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") await websocket.close(code=1008, reason="Unknown host")
return 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") await websocket.close(code=1008, reason="Unauthorized")
return return
try: try:
user = await get_current_user_ws(token) user = await auth.get_current_user_ws(access_token)
except Exception: except Exception:
await websocket.close(code=1008, reason="Unauthorized") await websocket.close(code=1008, reason="Unauthorized")
return return
+12 -7
View File
@@ -14,7 +14,7 @@
import InfoEngine from "./routes/InfoEngine.svelte"; import InfoEngine from "./routes/InfoEngine.svelte";
import Login from "./routes/Login.svelte"; import Login from "./routes/Login.svelte";
import { onMount } from "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([ const KNOWN_PAGES = new Set([
"dashboard", "dashboard",
@@ -106,11 +106,10 @@
// Try proxy auth first (Authelia forward-auth sets Remote-User header) // Try proxy auth first (Authelia forward-auth sets Remote-User header)
try { try {
const proxyRes = await fetch("/api/auth/proxy"); const proxyRes = await fetch("/api/auth/proxy", { credentials: "same-origin" });
if (proxyRes.ok) { if (proxyRes.ok) {
const data = await proxyRes.json(); const data = await proxyRes.json();
setToken(data.access_token); setToken(data.access_token);
setRefreshToken(data.refresh_token);
authorized = true; authorized = true;
await fetchUserInfo(); await fetchUserInfo();
const requestedPage = getRequestedPage(); const requestedPage = getRequestedPage();
@@ -123,11 +122,13 @@
} }
// Regular token auth check // Regular token auth check
const token = getToken(); if (!getToken()) {
if (!token) { const refreshed = await tryRefreshSession();
if (!refreshed) {
loading = false; loading = false;
authorized = false; authorized = false;
return; return;
}
} }
try { try {
@@ -160,9 +161,13 @@
} }
} }
function logout() { async function logout() {
try {
await logoutSession();
} catch (e) {
console.error("Logout failed:", e);
}
setToken(""); setToken("");
setRefreshToken("");
window.location.reload(); window.location.reload();
} }
</script> </script>
+18 -30
View File
@@ -1,5 +1,5 @@
const BASE = "/api"; const BASE = "/api";
let token = localStorage.getItem("token") || ""; let token = "";
// Current user info populated after auth // Current user info populated after auth
export let currentUser = { username: "", role: "", pages: [] }; export let currentUser = { username: "", role: "", pages: [] };
@@ -18,33 +18,24 @@ export function getToken() {
return token; return token;
} }
export function setRefreshToken(t) { // Refresh token is cookie-managed server-side.
if (t) localStorage.setItem("refresh_token", t); export function setRefreshToken() {}
else localStorage.removeItem("refresh_token");
}
function getRefreshToken() {
return localStorage.getItem("refresh_token") || "";
}
let refreshPromise = null; let refreshPromise = null;
async function tryRefresh() { export async function tryRefreshSession() {
if (refreshPromise) return refreshPromise; if (refreshPromise) return refreshPromise;
const rt = getRefreshToken();
if (!rt) return false;
refreshPromise = (async () => { refreshPromise = (async () => {
try { try {
const r = await fetch(BASE + "/auth/refresh", { const r = await fetch(BASE + "/auth/refresh", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: rt }), credentials: "same-origin",
}); });
if (!r.ok) return false; if (!r.ok) return false;
const data = await r.json(); const data = await r.json();
setToken(data.access_token); setToken(data.access_token || "");
setRefreshToken(data.refresh_token); return !!data.access_token;
return true;
} catch { } catch {
return false; return false;
} finally { } finally {
@@ -54,15 +45,6 @@ async function tryRefresh() {
return refreshPromise; 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 = {}) { async function request(path, opts = {}) {
const headers = opts.headers || {}; const headers = opts.headers || {};
if (token) headers["Authorization"] = `Bearer ${token}`; if (token) headers["Authorization"] = `Bearer ${token}`;
@@ -73,27 +55,29 @@ async function request(path, opts = {}) {
delete opts.json; delete opts.json;
} }
const fetchOpts = { ...opts, headers };
if (path.startsWith("/auth/")) fetchOpts.credentials = "same-origin";
try { try {
let r = await fetch(BASE + path, { ...opts, headers }); let r = await fetch(BASE + path, fetchOpts);
if (r.status === 401 && !path.includes("/auth/refresh")) { if (r.status === 401 && !path.includes("/auth/refresh")) {
const refreshed = await tryRefresh(); const refreshed = await tryRefreshSession();
if (refreshed) { if (refreshed) {
headers["Authorization"] = `Bearer ${token}`; headers["Authorization"] = `Bearer ${token}`;
r = await fetch(BASE + path, { ...opts, headers }); r = await fetch(BASE + path, fetchOpts);
} }
} }
if (r.status === 401) { if (r.status === 401) {
setToken(""); setToken("");
setRefreshToken("");
throw new Error("Unauthorized"); throw new Error("Unauthorized");
} }
if (!r.ok) { if (!r.ok) {
const error = new Error(`HTTP ${r.status}`); const error = new Error(`HTTP ${r.status}`);
error.status = r.status; error.status = r.status;
try { error.body = await r.json(); } catch { } try { error.body = await r.json(); } catch {}
throw error; throw error;
} }
return r.json(); return r.json();
@@ -140,6 +124,10 @@ export function checkAuth() {
return request("/auth/me"); return request("/auth/me");
} }
export function logout() {
return request("/auth/logout", { method: "POST" });
}
export function getPreferences() { export function getPreferences() {
return get("/auth/preferences"); return get("/auth/preferences");
} }
+1 -3
View File
@@ -1,5 +1,5 @@
<script> <script>
import { login, setToken, setRefreshToken, get, post } from "../lib/api.js"; import { login, setToken } from "../lib/api.js";
import { fade, fly } from "svelte/transition"; import { fade, fly } from "svelte/transition";
let username = $state(""); let username = $state("");
@@ -56,7 +56,6 @@
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || "Passkey verification failed"); } if (!res.ok) { const e = await res.json(); throw new Error(e.detail || "Passkey verification failed"); }
const data = await res.json(); const data = await res.json();
setToken(data.access_token); setToken(data.access_token);
setRefreshToken(data.refresh_token);
window.location.reload(); window.location.reload();
} catch (e) { } catch (e) {
if (e.name === "NotAllowedError") { error = "Passkey authentication cancelled"; } if (e.name === "NotAllowedError") { error = "Passkey authentication cancelled"; }
@@ -79,7 +78,6 @@
const res = await login(payload); // api.js helper sends JSON const res = await login(payload); // api.js helper sends JSON
setToken(res.access_token); setToken(res.access_token);
setRefreshToken(res.refresh_token);
// Determine if we need to reload or just update state. // Determine if we need to reload or just update state.
// Reload is safest to clear any stale state. // Reload is safest to clear any stale state.
window.location.reload(); window.location.reload();
+10 -8
View File
@@ -1,7 +1,7 @@
<script> <script>
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { VoiceSession } from "../lib/voice.js"; 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 = "__DASHBOARD_PERSISTENCE__:";
const PERSISTENCE_STATUS_PREFIX_BYTES = new TextEncoder().encode(PERSISTENCE_STATUS_PREFIX); const PERSISTENCE_STATUS_PREFIX_BYTES = new TextEncoder().encode(PERSISTENCE_STATUS_PREFIX);
@@ -253,14 +253,16 @@
} }
async function connect(forceRefresh = false) { async function connect(forceRefresh = false) {
const token = await getWebSocketToken(forceRefresh); if (forceRefresh) {
if (!token) { const refreshed = await tryRefreshSession();
handleAuthExpired(); if (!refreshed) {
return null; handleAuthExpired();
return null;
}
} }
const ws = new WebSocket( 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"; ws.binaryType = "arraybuffer";
@@ -307,9 +309,9 @@
if (e.code === 1008 && reason === "Unauthorized") { if (e.code === 1008 && reason === "Unauthorized") {
if (!closedManually && !authRecoveryInFlight) { if (!closedManually && !authRecoveryInFlight) {
authRecoveryInFlight = true; authRecoveryInFlight = true;
const recoveredToken = await getWebSocketToken(true); const refreshed = await tryRefreshSession();
authRecoveryInFlight = false; authRecoveryInFlight = false;
if (recoveredToken) { if (refreshed) {
reconnecting = true; reconnecting = true;
updateTab(tabId, { updateTab(tabId, {
connected: false, connected: false,