From 651bc0580eb4ac5a5b268291b423f19b0aa8b37b Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Thu, 19 Feb 2026 03:47:48 +0800 Subject: [PATCH] Phase 7: Auth & Security upgrade (JWT, 2FA, Login UI) --- dashboard/backend/auth.py | 91 +++++++++++ dashboard/backend/config.py | 9 ++ dashboard/backend/main.py | 21 ++- dashboard/backend/requirements.txt | 3 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 164 bytes .../__pycache__/terminal.cpython-313.pyc | Bin 0 -> 4606 bytes dashboard/backend/routers/auth.py | 86 +++++++++++ dashboard/backend/routers/terminal.py | 13 +- dashboard/frontend/src/App.svelte | 92 ++++++++--- .../frontend/src/components/Sidebar.svelte | 9 +- dashboard/frontend/src/lib/api.js | 64 +++++++- dashboard/frontend/src/routes/Login.svelte | 145 ++++++++++++++++++ 12 files changed, 495 insertions(+), 38 deletions(-) create mode 100644 dashboard/backend/auth.py create mode 100644 dashboard/backend/routers/__pycache__/__init__.cpython-313.pyc create mode 100644 dashboard/backend/routers/__pycache__/terminal.cpython-313.pyc create mode 100644 dashboard/backend/routers/auth.py create mode 100644 dashboard/frontend/src/routes/Login.svelte 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 0000000000000000000000000000000000000000..b98b1a8c2ce803ecbb0c398491eb506d0b4fb178 GIT binary patch literal 164 zcmey&%ge<81gm~d%>>bpK?DpiLK&Y~fQ+dO=?t2Tek&P@n1H;`AgNo<`k}?CMaBAA znYp=@>H0;f1^LDLd5OikCHeU|#ri3U#TiNYiA5>;Nr}nXsd*{-Mfs&AAPw>HnR%Hd l@$q^EmA5!-a`RJ4b5iY!Sb=7O>?{T`J~A^hG8QodSpcjHDjom; literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4758811f5a1235355115167763031163dc7b615c GIT binary patch literal 4606 zcmdrQTWlN0agTRA-jNhViL#znte5LBHcdH_ABvxf6j+a1YI$a3SBBLSd6F4Zcf{UF zvYfUMngo&U0#=-+RuZ6Q(2uA{TKKOuk5<>r^1J>MY$jTHTuOS5~TsOk2ki(ok zc43zddCc2Tz=93k*lj}*ivV5So-PSXT{4!tyx3c_D#M3;C%kp&gfCXkmUQ4wZWV9l z6#j&y2qy}f1;t&7o)ezqaA1fFi+3qOPnak2v)X`SBu;A<2|YmDQ)a@@buD2LPrH`U z^rT7TF>SzHB@{+X4JTaAddLU(3-GKw;X&x6|71Zus)19-Q7n&$p^vX2YAN~}ideqv zHR@hY;dEEr6=RxZ;s7cxc48(wSz*hD-8|Y9UIAUSxH>_Pr+9=$x!3p}w;%R9g!;gW zt1U)P^hn#eHvTGSpP!M&0t|Ct9b%j#aIRs$&i4h`Y^<1J4s_^sAucH5Q^ycee}hmS zm*`M4tPUOZGcDBQ)0Qv_2RbV}L<#nIwAG?D4IrJ^vH6v@Z3!=Zo{}o^Kuh zuS1Hg$WiV)%C2Nuh;3w;10CJz?zlf*(CkvY5LuEgD!xiIT+rumKjv^T4D=~F64-fk z&tvGZeDAAe^bTY`^r-jglH!j`dx22>BltXwXn?RdP=D{d)8)9Vh>Ax*N`dWDFN06j zvlZep+l>;iXV1WF1DlO~k6{i3Ef<1Bj}jCR)3hSe)phT;;tVQ*uWc{K?rD52g@Y$6<+hRC8QUqzvLV)0&nc zF2f`OC_YJCV@7#kSVlS2_DBvnf#@~~=J8txxVl0s8UL-o{8 zxz&NRX3`{6GciHhqH(C5%;{){zFs>jTJpvo92-P*n{ZGh*P2ez~6dRWvPgYN!aG=Y{~ zvRt%ST)I?ReR=rOaJGHEG@J{DXM^Eep}K5KE)xJ~?^TaZSaW0lmwMHFBQ7($2)W6q!bp#BeyJ`^B zV$?Kti$AC$8&KHQV+R{Vptx#FA`F0u!UAy(CUG?|5DpWr-=qR-pkb&u5vIimX;{}P zkGMn>C{239O`zM-hQ;FY%aKUsow35TL`#})So;C6#IbSB3s;`flt%(TV^T=PCk-(l8D+FEiszGg_IyLq*?vi zC@j*SHjE7MCBXG8t>043)5L?dgq9lB2%pw;kYeEx_?{3)^bEK*aStYRD^1)fBLTo; zyEnv}_h@HtoAPXDkMd(8tP+mllm!`3(`hEOBMwfc>Ip4Pgwv^X8bqgtATo$Y={$V& znb;BH8BdLuO#Q&H0Rrt6?48VbG|K$fwGInIV0OV+jwZwm<_b%q}Exf zbx{g@E+SvS#qhcCg{?VZ!>q7jsj~XY);G4k8JQExCORk2EP4GGx18T{Aw1D`$K#z; zUpTWQc`r)mrKySqsfyzAdHI(gQ2KD9OLoBr8?6!}yT zU+(xt$K)Ytob%P*@YT-y>L=R25WMc9d*!^l>~k+FD$fP$Zv^Y#>iBKfd@wrE zxh#~ps}=*rOXZbUysvxT^yi8jCfgPZOQ-8E{5)Hq9eR7)Vp-+%?pJ;~CEN*>O>@(= zQ!QD~d}zzNZC8bNIE(_~9n;5WgqtOi z#iFw5rdO-8P1(`6_bvsCFH4uC>56RIjQCb}u3^_)!>;*;Jqy8>dljfKbnk~8lAq@8 zNyuG#uZu&W&G$bqNB&KaBHabIBtN7`cfr4xMI;yJr1Ck5DK_6m)HM9BesuZdD<>DE z2C8=NdGA!yG~yua-EuB>008Su#$TSGk|?s^&5)hJvq-$?OhuB%D7zO#KZ3_hqT z?`lCGG;v+Kgb$kA_QBx8Mh+%EY_j`Z9DUQ`?H2iuc-Qy0p^w}g#YLXta%(-1{%u=p zLr;+Zdyt1gxX?}-c51;i`{8!l(ZUqd&lkKOI^r3GOH|FIFimK7v}2m%h@g%@;tJ7( zlNiC6rX2b&L_gfjJfs#o(td|{aAm`MAEjpRsLJAmUbq}kG>>w(%i?lubCM)Gas zy^RX&pC8_QjP}l%!De$VmG&_S*$Td_>R)%q_euvhsHv(9Fa literal 0 HcmV?d00001 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} + +
+ +
+
+
+