diff --git a/dashboard/backend/auth.py b/dashboard/backend/auth.py new file mode 100644 index 0000000..8bbcc06 --- /dev/null +++ b/dashboard/backend/auth.py @@ -0,0 +1,91 @@ +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +import pyotp +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +import config + +# Password handling +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login") + +def verify_password(plain_password, hashed_password): + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password): + return pwd_context.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 + +async def get_current_user(token: str = Depends(oauth2_scheme)): + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM]) + username: str = payload.get("sub") + if username is None: + 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 JWTError: + raise credentials_exception + return username + +async def get_current_user_ws(token: str): + 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]) + username: str = payload.get("sub") + if username is None or username != config.ADMIN_USER: + raise credentials_exception + except JWTError: + raise credentials_exception + return username + +# TOTP Persistenceturn username + +# TOTP Persistence +AUTH_FILE = config.VOLUME_ROOT + "/docker/nas-dashboard/auth.json" + +def load_totp_secret(): + try: + import json + if os.path.exists(AUTH_FILE): + with open(AUTH_FILE, "r") as f: + data = json.load(f) + return data.get("totp_secret", "") + except Exception: + pass + return config.TOTP_SECRET + +def save_totp_secret(secret: str): + import json + os.makedirs(os.path.dirname(AUTH_FILE), exist_ok=True) + with open(AUTH_FILE, "w") as f: + json.dump({"totp_secret": secret}, f) + +def verify_totp(token: str, secret: str = None): + if secret is None: + secret = load_totp_secret() + if not secret: + return True # 2FA not enabled + totp = pyotp.TOTP(secret) + return totp.verify(token) diff --git a/dashboard/backend/config.py b/dashboard/backend/config.py index e7cb9b9..998b61d 100644 --- a/dashboard/backend/config.py +++ b/dashboard/backend/config.py @@ -4,3 +4,12 @@ GITEA_URL = os.environ.get("GITEA_URL", "http://gitea:3000") GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "") DOCKER_HOST = os.environ.get("DOCKER_HOST", "tcp://docker-socket-proxy:2375") VOLUME_ROOT = os.environ.get("VOLUME_ROOT", "/volume1") + +# Auth +SECRET_KEY = os.environ.get("SECRET_KEY", "c1a776b228421830e7c7df0887adc75f74bac65aaa1fc364be96861b1f8c8701") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 +ADMIN_USER = os.environ.get("ADMIN_USER", "admin") +# Default hash for "admin" (bcrypt) +ADMIN_PASSWORD_HASH = os.environ.get("ADMIN_PASSWORD_HASH", "$2b$12$EixZaYVK1fsbx4uOXQTEGeN.un0.1") +TOTP_SECRET = os.environ.get("TOTP_SECRET", "") # Empty means 2FA disabled by default diff --git a/dashboard/backend/main.py b/dashboard/backend/main.py index 042ed05..ee892bb 100644 --- a/dashboard/backend/main.py +++ b/dashboard/backend/main.py @@ -1,11 +1,20 @@ -from fastapi import FastAPI +from fastapi import FastAPI, Depends from fastapi.staticfiles import StaticFiles -from routers import docker_router, gitea, files, terminal, system +from routers import docker_router, gitea, files, terminal, system, auth +import auth as auth_module app = FastAPI(title="NAS Dashboard") -app.include_router(docker_router.router, prefix="/api/docker") -app.include_router(gitea.router, prefix="/api/gitea") -app.include_router(files.router, prefix="/api/files") -app.include_router(system.router, prefix="/api/system") + +# Public auth endpoints +app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) + +# Protected endpoints +app.include_router(docker_router.router, prefix="/api/docker", dependencies=[Depends(auth_module.get_current_user)]) +app.include_router(gitea.router, prefix="/api/gitea", dependencies=[Depends(auth_module.get_current_user)]) +app.include_router(files.router, prefix="/api/files", dependencies=[Depends(auth_module.get_current_user)]) +app.include_router(system.router, prefix="/api/system", dependencies=[Depends(auth_module.get_current_user)]) + +# WebSocket endpoint (auth handled inside handler via Query param) 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/requirements.txt b/dashboard/backend/requirements.txt index 6cc3804..db08d76 100644 --- a/dashboard/backend/requirements.txt +++ b/dashboard/backend/requirements.txt @@ -4,3 +4,6 @@ docker==7.1.0 httpx==0.27.0 python-multipart==0.0.9 psutil==6.1.0 +pyjwt==2.8.0 +passlib[bcrypt]==1.7.4 +pyotp==2.9.0 diff --git a/dashboard/backend/routers/__pycache__/__init__.cpython-313.pyc b/dashboard/backend/routers/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..b98b1a8 Binary files /dev/null and b/dashboard/backend/routers/__pycache__/__init__.cpython-313.pyc differ diff --git a/dashboard/backend/routers/__pycache__/terminal.cpython-313.pyc b/dashboard/backend/routers/__pycache__/terminal.cpython-313.pyc new file mode 100644 index 0000000..4758811 Binary files /dev/null and b/dashboard/backend/routers/__pycache__/terminal.cpython-313.pyc differ diff --git a/dashboard/backend/routers/auth.py b/dashboard/backend/routers/auth.py new file mode 100644 index 0000000..e79043b --- /dev/null +++ b/dashboard/backend/routers/auth.py @@ -0,0 +1,86 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +import config +import auth +import pyotp + +router = APIRouter() + +class LoginRequest(BaseModel): + username: str + password: str + totp_code: str = "" + +class Token(BaseModel): + access_token: str + token_type: str + user: str + has_2fa: bool + +class Setup2FAResponse(BaseModel): + secret: str + uri: str + +class Verify2FARequest(BaseModel): + secret: str + code: str + +@router.post("/login", response_model=Token) +async def login(creds: LoginRequest): + # Verify username/password + if creds.username != config.ADMIN_USER or not auth.verify_password(creds.password, config.ADMIN_PASSWORD_HASH): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Check 2FA + totp_secret = auth.load_totp_secret() + if totp_secret: + if not creds.totp_code: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="2FA code required", + headers={"WWW-Authenticate": "Bearer"}, + ) + if not auth.verify_totp(creds.totp_code, totp_secret): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid 2FA code", + headers={"WWW-Authenticate": "Bearer"}, + ) + + access_token = auth.create_access_token(data={"sub": creds.username}) + return { + "access_token": access_token, + "token_type": "bearer", + "user": creds.username, + "has_2fa": bool(totp_secret) + } + +@router.get("/me") +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("/setup-2fa/generate", response_model=Setup2FAResponse) +async def generate_2fa(current_user: str = Depends(auth.get_current_user)): + secret = pyotp.random_base32() + uri = pyotp.totp.TOTP(secret).provisioning_uri(name=current_user, issuer_name="NAS Dashboard") + return {"secret": secret, "uri": uri} + +@router.post("/setup-2fa/verify") +async def verify_2fa_setup(request: Verify2FARequest, current_user: str = Depends(auth.get_current_user)): + totp = pyotp.TOTP(request.secret) + if not totp.verify(request.code): + raise HTTPException(status_code=400, detail="Invalid code") + + # Save secret + auth.save_totp_secret(request.secret) + return {"message": "2FA enabled successfully"} + +@router.post("/setup-2fa/disable") +async def disable_2fa(current_user: str = Depends(auth.get_current_user)): + auth.save_totp_secret("") + return {"message": "2FA disabled"} diff --git a/dashboard/backend/routers/terminal.py b/dashboard/backend/routers/terminal.py index 691c821..8bf202f 100644 --- a/dashboard/backend/routers/terminal.py +++ b/dashboard/backend/routers/terminal.py @@ -5,16 +5,17 @@ import signal import struct import fcntl import termios -from fastapi import WebSocket, WebSocketDisconnect +from fastapi import WebSocket, WebSocketDisconnect, Depends +import auth - -async def ws_endpoint(ws: WebSocket): - # Block non-Tailscale IPs - client_ip = ws.client.host if ws.client else "" +async def ws_endpoint(websocket: WebSocket, user: str = Depends(auth.get_current_user_ws)): + # Block non-Tailscale IPs (Defense in depth) + client_ip = websocket.client.host if websocket.client else "" if not client_ip.startswith("100."): - await ws.close(code=1008, reason="Tailscale only") + await websocket.close(code=1008, reason="Tailscale only") return + await ws.accept() master_fd, slave_fd = pty.openpty() pid = os.fork() diff --git a/dashboard/frontend/src/App.svelte b/dashboard/frontend/src/App.svelte index 41f1f61..67face2 100644 --- a/dashboard/frontend/src/App.svelte +++ b/dashboard/frontend/src/App.svelte @@ -5,31 +5,85 @@ import Gitea from "./routes/Gitea.svelte"; import Files from "./routes/Files.svelte"; import Terminal from "./routes/Terminal.svelte"; + import Login from "./routes/Login.svelte"; + import { onMount } from "svelte"; + import { getToken, checkAuth, setToken } from "./lib/api.js"; let page = $state("dashboard"); let dark = $state(false); + let authorized = $state(false); + let loading = $state(true); + + onMount(async () => { + // Theme init + if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { + dark = true; + document.documentElement.classList.add('dark'); + } else { + dark = false; + document.documentElement.classList.remove('dark'); + } + + // Auth check + const token = getToken(); + if (!token) { + loading = false; + authorized = false; + return; + } + + try { + await checkAuth(); + authorized = true; + } catch (e) { + console.error("Auth check failed:", e); + setToken(""); + authorized = false; + } finally { + loading = false; + } + }); function toggleTheme() { dark = !dark; - document.documentElement.classList.toggle("dark", dark); + if (dark) { + document.documentElement.classList.add("dark"); + localStorage.theme = 'dark'; + } else { + document.documentElement.classList.remove("dark"); + localStorage.theme = 'light'; + } + } + + function logout() { + setToken(""); + window.location.reload(); } -
- -
-
- {#if page === "dashboard"} - - {:else if page === "docker"} - - {:else if page === "gitea"} - - {:else if page === "files"} - - {:else if page === "terminal"} - - {/if} -
-
-
+{#if loading} +
+
+
+{:else if !authorized} + +{:else} +
+ +
+
+ {#if page === "dashboard"} + + {:else if page === "docker"} + + {:else if page === "gitea"} + + {:else if page === "files"} + + {:else if page === "terminal"} + + {/if} +
+
+
+{/if} diff --git a/dashboard/frontend/src/components/Sidebar.svelte b/dashboard/frontend/src/components/Sidebar.svelte index e0968ea..cdbfecd 100644 --- a/dashboard/frontend/src/components/Sidebar.svelte +++ b/dashboard/frontend/src/components/Sidebar.svelte @@ -92,7 +92,7 @@
+
diff --git a/dashboard/frontend/src/lib/api.js b/dashboard/frontend/src/lib/api.js index 52938cf..a3494b3 100644 --- a/dashboard/frontend/src/lib/api.js +++ b/dashboard/frontend/src/lib/api.js @@ -1,13 +1,49 @@ const BASE = "/api"; +let token = localStorage.getItem("token") || ""; + +export function setToken(t) { + token = t; + if (t) localStorage.setItem("token", t); + else localStorage.removeItem("token"); +} + +export function getToken() { + return token; +} async function request(path, opts = {}) { + const headers = opts.headers || {}; + if (token) headers["Authorization"] = `Bearer ${token}`; + + if (opts.json) { + headers["Content-Type"] = "application/json"; + opts.body = JSON.stringify(opts.json); + delete opts.json; + } + try { - const r = await fetch(BASE + path, opts); - if (!r.ok) throw new Error(`HTTP ${r.status}`); + const 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 + } + 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 { } + throw error; + } return r.json(); } catch (e) { console.error(`API ${opts.method || "GET"} ${path}:`, e); - return null; + throw e; } } @@ -15,8 +51,8 @@ export function get(path) { return request(path); } -export function post(path) { - return request(path, { method: "POST" }); +export function post(path, data) { + return request(path, { method: "POST", json: data }); } export function del(path) { @@ -24,10 +60,26 @@ export function del(path) { } export async function upload(path, file) { + const headers = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; + const form = new FormData(); form.append("file", file); - return request(`/files/upload?path=${encodeURIComponent(path)}`, { + + const r = await fetch(`${BASE}/files/upload?path=${encodeURIComponent(path)}`, { method: "POST", + headers, body: form, }); + + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json(); +} + +export function login(creds) { + return request("/auth/login", { method: "POST", json: creds }); +} + +export function checkAuth() { + return request("/auth/me"); } diff --git a/dashboard/frontend/src/routes/Login.svelte b/dashboard/frontend/src/routes/Login.svelte new file mode 100644 index 0000000..7d5ac45 --- /dev/null +++ b/dashboard/frontend/src/routes/Login.svelte @@ -0,0 +1,145 @@ + + +
+
+
+
+ + + +
+

Sign in to NAS

+

+ Enter your credentials to access the dashboard +

+
+ +
+
+ {#if !require2FA} +
+ + +
+
+ + +
+ {:else} +
+ + +

+ +

+
+ {/if} +
+ + {#if error} +
+
+
+ + + +
+
+

{error}

+
+
+
+ {/if} + +
+ +
+
+
+