diff --git a/dashboard/backend/auth.py b/dashboard/backend/auth.py index eef5d5e..5005a18 100644 --- a/dashboard/backend/auth.py +++ b/dashboard/backend/auth.py @@ -19,13 +19,15 @@ def get_password_hash(password): def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): to_encode = data.copy() - if expires_delta: - expire = datetime.utcnow() + expires_delta - else: - expire = datetime.utcnow() + timedelta(minutes=15) - to_encode.update({"exp": expire}) - encoded_jwt = jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM) - return encoded_jwt + expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15)) + 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.utcnow() + timedelta(minutes=config.REFRESH_TOKEN_EXPIRE_MINUTES) + to_encode.update({"exp": expire, "type": "refresh"}) + return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM) async def get_current_user(token: str = Depends(oauth2_scheme)): credentials_exception = HTTPException( @@ -35,13 +37,11 @@ async def get_current_user(token: str = Depends(oauth2_scheme)): ) try: payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM]) - username: str = payload.get("sub") - if username is None: + if payload.get("type") != "access": + raise credentials_exception + username: str = payload.get("sub") + if username is None or username != config.ADMIN_USER: raise credentials_exception - # In a real app, we'd check DB here. - # For this single-user system, we just check if it matches config. - if username != config.ADMIN_USER: - raise credentials_exception except jwt.PyJWTError: raise credentials_exception return username @@ -53,6 +53,8 @@ async def get_current_user_ws(token: str): ) try: payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM]) + if payload.get("type") != "access": + raise credentials_exception username: str = payload.get("sub") if username is None or username != config.ADMIN_USER: raise credentials_exception @@ -63,6 +65,26 @@ async def get_current_user_ws(token: str): # TOTP Persistence AUTH_FILE = config.VOLUME_ROOT + "/docker/nas-dashboard/auth.json" import json, os +import base64 +import hashlib +from cryptography.fernet import Fernet + +def _get_fernet(): + key = base64.urlsafe_b64encode(hashlib.sha256(config.SECRET_KEY.encode()).digest()) + return Fernet(key) + +def _encrypt(plaintext: str) -> str: + if not plaintext: + return "" + return _get_fernet().encrypt(plaintext.encode()).decode() + +def _decrypt(ciphertext: str) -> str: + if not ciphertext: + return "" + try: + return _get_fernet().decrypt(ciphertext.encode()).decode() + except Exception: + return ciphertext # fallback for unencrypted legacy values def _load_auth_data(): try: @@ -79,11 +101,14 @@ def _save_auth_data(data): json.dump(data, f) def load_totp_secret(): - return _load_auth_data().get("totp_secret", "") or config.TOTP_SECRET + encrypted = _load_auth_data().get("totp_secret", "") + if not encrypted: + return config.TOTP_SECRET + return _decrypt(encrypted) def save_totp_secret(secret: str): data = _load_auth_data() - data["totp_secret"] = secret + data["totp_secret"] = _encrypt(secret) if secret else "" _save_auth_data(data) def load_password_hash(): @@ -101,3 +126,19 @@ def verify_totp(token: str, secret: str = None): return True # 2FA not enabled totp = pyotp.TOTP(secret) return totp.verify(token) + +# Passkey credentials +def load_passkey_credentials(): + return _load_auth_data().get("passkey_credentials", []) + +def save_passkey_credential(cred): + data = _load_auth_data() + creds = data.get("passkey_credentials", []) + creds.append(cred) + data["passkey_credentials"] = creds + _save_auth_data(data) + +def clear_passkey_credentials(): + data = _load_auth_data() + data["passkey_credentials"] = [] + _save_auth_data(data) diff --git a/dashboard/backend/config.py b/dashboard/backend/config.py index 83947ab..024608e 100644 --- a/dashboard/backend/config.py +++ b/dashboard/backend/config.py @@ -9,7 +9,8 @@ VOLUME_ROOT = os.environ.get("VOLUME_ROOT", "/volume1") # Auth SECRET_KEY = os.environ.get("SECRET_KEY", "") ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days +ACCESS_TOKEN_EXPIRE_MINUTES = 30 +REFRESH_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days ADMIN_USER = os.environ.get("ADMIN_USER", "admin") ADMIN_PASSWORD_HASH = os.environ.get("ADMIN_PASSWORD_HASH", "") TOTP_SECRET = os.environ.get("TOTP_SECRET", "") @@ -28,5 +29,8 @@ TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "") TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "") TELEGRAM_PROXY = os.environ.get("TELEGRAM_PROXY", "") +WEBAUTHN_RP_ID = os.environ.get("WEBAUTHN_RP_ID", "nas.jimmygan.com") +WEBAUTHN_ORIGIN = os.environ.get("WEBAUTHN_ORIGIN", "https://nas.jimmygan.com") + # CORS CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "https://nas.jimmygan.com").split(",") diff --git a/dashboard/backend/main.py b/dashboard/backend/main.py index c647d69..c8788c3 100644 --- a/dashboard/backend/main.py +++ b/dashboard/backend/main.py @@ -10,7 +10,10 @@ import auth as auth_module import asyncio import httpx -from config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, CORS_ORIGINS +import logging +import time +import jwt +from config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, CORS_ORIGINS, SECRET_KEY, ALGORITHM, VOLUME_ROOT limiter = Limiter(key_func=get_remote_address) app = FastAPI(title="NAS Dashboard") @@ -35,6 +38,38 @@ async def security_headers(request: Request, call_next): response.headers["X-Frame-Options"] = "DENY" response.headers["X-XSS-Protection"] = "1; mode=block" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' wss://nas.jimmygan.com; frame-ancestors 'none'" + return response + +# Audit logger +import os +_audit_log_path = os.path.join(VOLUME_ROOT, "docker/nas-dashboard/audit.log") +os.makedirs(os.path.dirname(_audit_log_path), exist_ok=True) +_audit_logger = logging.getLogger("audit") +_audit_logger.setLevel(logging.INFO) +_audit_handler = logging.FileHandler(_audit_log_path) +_audit_handler.setFormatter(logging.Formatter("%(message)s")) +_audit_logger.addHandler(_audit_handler) +_audit_logger.propagate = False + +@app.middleware("http") +async def audit_log(request: Request, call_next): + start = time.time() + response = await call_next(request) + duration = int((time.time() - start) * 1000) + user = "-" + try: + auth_header = request.headers.get("authorization", "") + if auth_header.startswith("Bearer "): + payload = jwt.decode(auth_header[7:], SECRET_KEY, algorithms=[ALGORITHM]) + user = payload.get("sub", "-") + except Exception: + pass + _audit_logger.info( + "%s %s %s %s %s %d %dms", + time.strftime("%Y-%m-%dT%H:%M:%S"), request.client.host, user, + request.method, request.url.path, response.status_code, duration, + ) return response async def monitor_containers(): diff --git a/dashboard/backend/requirements.txt b/dashboard/backend/requirements.txt index e0d3476..758f167 100644 --- a/dashboard/backend/requirements.txt +++ b/dashboard/backend/requirements.txt @@ -9,3 +9,5 @@ passlib==1.7.4 pyotp==2.9.0 asyncssh==2.17.0 slowapi==0.1.9 +webauthn==2.7.1 +cryptography>=44.0.2 diff --git a/dashboard/backend/routers/auth.py b/dashboard/backend/routers/auth.py index 62eb01a..0bd82e5 100644 --- a/dashboard/backend/routers/auth.py +++ b/dashboard/backend/routers/auth.py @@ -1,13 +1,20 @@ from datetime import timedelta import asyncio +import time +import json import httpx from fastapi import APIRouter, Depends, HTTPException, status, Request +from fastapi.responses import JSONResponse from pydantic import BaseModel from slowapi import Limiter from slowapi.util import get_remote_address +import jwt import config import auth import pyotp +from webauthn import generate_registration_options, verify_registration_response, generate_authentication_options, verify_authentication_response +from webauthn.helpers.structs import PublicKeyCredentialDescriptor, UserVerificationRequirement +from webauthn.helpers import bytes_to_base64url, base64url_to_bytes, options_to_json router = APIRouter() limiter = Limiter(key_func=get_remote_address) @@ -19,10 +26,14 @@ class LoginRequest(BaseModel): class Token(BaseModel): access_token: str + refresh_token: str token_type: str user: str has_2fa: bool +class RefreshRequest(BaseModel): + refresh_token: str + class Setup2FAResponse(BaseModel): secret: str uri: str @@ -87,8 +98,10 @@ async def login(creds: LoginRequest, request: Request): data={"sub": creds.username}, expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES), ) + refresh_token = auth.create_refresh_token(data={"sub": creds.username}) return { - "access_token": access_token, + "access_token": access_token, + "refresh_token": refresh_token, "token_type": "bearer", "user": creds.username, "has_2fa": bool(totp_secret) @@ -99,6 +112,24 @@ async def read_users_me(current_user: str = Depends(auth.get_current_user)): totp_secret = auth.load_totp_secret() return {"username": current_user, "has_2fa": bool(totp_secret)} +@router.post("/refresh") +async def refresh_token(req: RefreshRequest): + 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") + if username != config.ADMIN_USER: + raise HTTPException(status_code=401, detail="Invalid user") + except jwt.PyJWTError: + raise HTTPException(status_code=401, detail="Invalid refresh token") + access_token = auth.create_access_token( + data={"sub": username}, + expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES), + ) + refresh_token = auth.create_refresh_token(data={"sub": username}) + return {"access_token": access_token, "refresh_token": refresh_token} + @router.post("/setup-2fa/generate", response_model=Setup2FAResponse) async def generate_2fa(current_user: str = Depends(auth.get_current_user)): secret = pyotp.random_base32() @@ -133,3 +164,105 @@ async def change_password(req: ChangePasswordRequest, request: Request, current_ raise HTTPException(status_code=400, detail="Password must be at least 8 characters") auth.save_password_hash(auth.get_password_hash(req.new_password)) return {"message": "Password changed successfully"} + +# WebAuthn — in-memory challenge store with TTL +_challenges = {} + +def _store_challenge(challenge: bytes): + _challenges["current"] = (challenge, time.time()) + +def _get_challenge() -> bytes: + entry = _challenges.pop("current", None) + if not entry or time.time() - entry[1] > 300: + raise HTTPException(status_code=400, detail="Challenge expired") + return entry[0] + +@router.post("/passkey/register/options") +async def passkey_register_options(current_user: str = Depends(auth.get_current_user)): + existing = auth.load_passkey_credentials() + exclude = [PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"])) for c in existing] + options = generate_registration_options( + rp_id=config.WEBAUTHN_RP_ID, + rp_name="NAS Dashboard", + user_id=config.ADMIN_USER.encode(), + user_name=config.ADMIN_USER, + user_display_name=config.ADMIN_USER, + exclude_credentials=exclude, + ) + _store_challenge(options.challenge) + return JSONResponse(content=json.loads(options_to_json(options))) + +@router.post("/passkey/register/verify") +async def passkey_register_verify(request: Request, current_user: str = Depends(auth.get_current_user)): + body = await request.json() + challenge = _get_challenge() + try: + verification = verify_registration_response( + credential=body, + expected_challenge=challenge, + expected_rp_id=config.WEBAUTHN_RP_ID, + expected_origin=config.WEBAUTHN_ORIGIN, + ) + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + auth.save_passkey_credential({ + "credential_id": bytes_to_base64url(verification.credential_id), + "public_key": bytes_to_base64url(verification.credential_public_key), + "sign_count": verification.sign_count, + }) + return {"ok": True} + +@router.post("/passkey/login/options") +async def passkey_login_options(): + creds = auth.load_passkey_credentials() + if not creds: + raise HTTPException(status_code=404, detail="No passkeys registered") + allow = [PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"])) for c in creds] + options = generate_authentication_options( + rp_id=config.WEBAUTHN_RP_ID, + allow_credentials=allow, + user_verification=UserVerificationRequirement.PREFERRED, + ) + _store_challenge(options.challenge) + return JSONResponse(content=json.loads(options_to_json(options))) + +@router.post("/passkey/login/verify") +async def passkey_login_verify(request: Request): + body = await request.json() + challenge = _get_challenge() + creds = auth.load_passkey_credentials() + cred_id_b64 = body.get("id", "") + matched = next((c for c in creds if c["credential_id"] == cred_id_b64), None) + if not matched: + raise HTTPException(status_code=400, detail="Unknown credential") + try: + verification = verify_authentication_response( + credential=body, + expected_challenge=challenge, + expected_rp_id=config.WEBAUTHN_RP_ID, + expected_origin=config.WEBAUTHN_ORIGIN, + credential_public_key=base64url_to_bytes(matched["public_key"]), + credential_current_sign_count=matched["sign_count"], + ) + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + matched["sign_count"] = verification.new_sign_count + data = auth._load_auth_data() + data["passkey_credentials"] = creds + auth._save_auth_data(data) + username = config.ADMIN_USER + access_token = auth.create_access_token( + data={"sub": username}, + expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES), + ) + refresh_token = auth.create_refresh_token(data={"sub": username}) + return {"access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer", "user": username} + +@router.get("/passkey/list") +async def passkey_list(current_user: str = Depends(auth.get_current_user)): + return {"count": len(auth.load_passkey_credentials())} + +@router.post("/passkey/clear") +async def passkey_clear(current_user: str = Depends(auth.get_current_user)): + auth.clear_passkey_credentials() + return {"ok": True} diff --git a/dashboard/backend/routers/files.py b/dashboard/backend/routers/files.py index b4be4ed..81fe857 100644 --- a/dashboard/backend/routers/files.py +++ b/dashboard/backend/routers/files.py @@ -8,7 +8,7 @@ from config import VOLUME_ROOT router = APIRouter() BASE = Path(VOLUME_ROOT) -BLOCKED_DIRS = {"@appstore", "@docker", "@eaDir", "@S2S", "@sharesnap", "@tmp"} +BLOCKED_DIRS = {"@appstore", "@docker", "@eaDir", "@S2S", "@sharesnap", "@tmp", "docker"} def _safe_path(path: str) -> Path: diff --git a/dashboard/frontend/src/App.svelte b/dashboard/frontend/src/App.svelte index df8143f..cecfadc 100644 --- a/dashboard/frontend/src/App.svelte +++ b/dashboard/frontend/src/App.svelte @@ -9,7 +9,7 @@ import Settings from "./routes/Settings.svelte"; import Login from "./routes/Login.svelte"; import { onMount } from "svelte"; - import { getToken, checkAuth, setToken } from "./lib/api.js"; + import { getToken, checkAuth, setToken, setRefreshToken } from "./lib/api.js"; let page = $state("dashboard"); let dark = $state(false); @@ -60,6 +60,7 @@ function logout() { setToken(""); + setRefreshToken(""); window.location.reload(); } diff --git a/dashboard/frontend/src/lib/api.js b/dashboard/frontend/src/lib/api.js index a3494b3..747c263 100644 --- a/dashboard/frontend/src/lib/api.js +++ b/dashboard/frontend/src/lib/api.js @@ -11,6 +11,42 @@ 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") || ""; +} + +let refreshPromise = null; + +async function tryRefresh() { + 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 }), + }); + if (!r.ok) return false; + const data = await r.json(); + setToken(data.access_token); + setRefreshToken(data.refresh_token); + return true; + } catch { + return false; + } finally { + refreshPromise = null; + } + })(); + return refreshPromise; +} + async function request(path, opts = {}) { const headers = opts.headers || {}; if (token) headers["Authorization"] = `Bearer ${token}`; @@ -22,15 +58,19 @@ async function request(path, opts = {}) { } try { - const r = await fetch(BASE + path, { ...opts, headers }); + let r = await fetch(BASE + path, { ...opts, headers }); + + if (r.status === 401 && !path.includes("/auth/refresh")) { + const refreshed = await tryRefresh(); + if (refreshed) { + headers["Authorization"] = `Bearer ${token}`; + r = await fetch(BASE + path, { ...opts, headers }); + } + } if (r.status === 401) { - // Session expired or invalid setToken(""); - // Only reload if not already on login page (simple check) - if (!location.pathname.includes("login") && !token) { - // Let the app handle redirect, but clearer is to throw - } + setRefreshToken(""); throw new Error("Unauthorized"); } diff --git a/dashboard/frontend/src/routes/Login.svelte b/dashboard/frontend/src/routes/Login.svelte index 7d5ac45..79f33d1 100644 --- a/dashboard/frontend/src/routes/Login.svelte +++ b/dashboard/frontend/src/routes/Login.svelte @@ -1,5 +1,5 @@