Phase 7: Auth & Security upgrade (JWT, 2FA, Login UI)

This commit is contained in:
Gan, Jimmy
2026-02-19 03:47:48 +08:00
parent f3db7d3654
commit 651bc0580e
12 changed files with 495 additions and 38 deletions
+91
View File
@@ -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)
+9
View File
@@ -4,3 +4,12 @@ GITEA_URL = os.environ.get("GITEA_URL", "http://gitea:3000")
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "") GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
DOCKER_HOST = os.environ.get("DOCKER_HOST", "tcp://docker-socket-proxy:2375") DOCKER_HOST = os.environ.get("DOCKER_HOST", "tcp://docker-socket-proxy:2375")
VOLUME_ROOT = os.environ.get("VOLUME_ROOT", "/volume1") 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
+15 -6
View File
@@ -1,11 +1,20 @@
from fastapi import FastAPI from fastapi import FastAPI, Depends
from fastapi.staticfiles import StaticFiles 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 = FastAPI(title="NAS Dashboard")
app.include_router(docker_router.router, prefix="/api/docker")
app.include_router(gitea.router, prefix="/api/gitea") # Public auth endpoints
app.include_router(files.router, prefix="/api/files") app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(system.router, prefix="/api/system")
# 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.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")
+3
View File
@@ -4,3 +4,6 @@ docker==7.1.0
httpx==0.27.0 httpx==0.27.0
python-multipart==0.0.9 python-multipart==0.0.9
psutil==6.1.0 psutil==6.1.0
pyjwt==2.8.0
passlib[bcrypt]==1.7.4
pyotp==2.9.0
+86
View File
@@ -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"}
+7 -6
View File
@@ -5,16 +5,17 @@ import signal
import struct import struct
import fcntl import fcntl
import termios import termios
from fastapi import WebSocket, WebSocketDisconnect from fastapi import WebSocket, WebSocketDisconnect, Depends
import auth
async def ws_endpoint(websocket: WebSocket, user: str = Depends(auth.get_current_user_ws)):
async def ws_endpoint(ws: WebSocket): # Block non-Tailscale IPs (Defense in depth)
# Block non-Tailscale IPs client_ip = websocket.client.host if websocket.client else ""
client_ip = ws.client.host if ws.client else ""
if not client_ip.startswith("100."): if not client_ip.startswith("100."):
await ws.close(code=1008, reason="Tailscale only") await websocket.close(code=1008, reason="Tailscale only")
return return
await ws.accept() await ws.accept()
master_fd, slave_fd = pty.openpty() master_fd, slave_fd = pty.openpty()
pid = os.fork() pid = os.fork()
+73 -19
View File
@@ -5,31 +5,85 @@
import Gitea from "./routes/Gitea.svelte"; import Gitea from "./routes/Gitea.svelte";
import Files from "./routes/Files.svelte"; import Files from "./routes/Files.svelte";
import Terminal from "./routes/Terminal.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 page = $state("dashboard");
let dark = $state(false); 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() { function toggleTheme() {
dark = !dark; 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();
} }
</script> </script>
<div class="flex h-screen overflow-hidden bg-surface-50 {dark ? 'dark bg-surface-900' : ''}"> {#if loading}
<Sidebar bind:page {dark} {toggleTheme} /> <div class="flex h-screen items-center justify-center bg-surface-50 dark:bg-surface-950">
<main class="flex-1 overflow-auto"> <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
<div class="max-w-[1400px] mx-auto p-6 lg:p-8"> </div>
{#if page === "dashboard"} {:else if !authorized}
<Dashboard /> <Login />
{:else if page === "docker"} {:else}
<Docker /> <div class="flex h-screen overflow-hidden bg-surface-50 {dark ? 'dark bg-surface-900' : ''}">
{:else if page === "gitea"} <Sidebar bind:page {dark} {toggleTheme} {logout} />
<Gitea /> <main class="flex-1 overflow-auto">
{:else if page === "files"} <div class="max-w-[1400px] mx-auto p-6 lg:p-8">
<Files /> {#if page === "dashboard"}
{:else if page === "terminal"} <Dashboard />
<Terminal /> {:else if page === "docker"}
{/if} <Docker />
</div> {:else if page === "gitea"}
</main> <Gitea />
</div> {:else if page === "files"}
<Files />
{:else if page === "terminal"}
<Terminal />
{/if}
</div>
</main>
</div>
{/if}
@@ -92,7 +92,7 @@
<div class="px-4 pb-4 pt-2 border-t border-surface-200 {dark ? 'border-surface-700' : ''}"> <div class="px-4 pb-4 pt-2 border-t border-surface-200 {dark ? 'border-surface-700' : ''}">
<button <button
onclick={toggleTheme} onclick={toggleTheme}
class="w-full px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 text-surface-500 hover:bg-surface-100 transition-all duration-150 {dark ? 'hover:bg-surface-700 text-surface-400' : ''}" class="w-full px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 text-surface-500 hover:bg-surface-100 transition-all duration-150 mb-1 {dark ? 'hover:bg-surface-700 text-surface-400' : ''}"
> >
{#if dark} {#if dark}
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /></svg> <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
@@ -102,5 +102,12 @@
Dark Mode Dark Mode
{/if} {/if}
</button> </button>
<button
onclick={logout}
class="w-full px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 text-surface-500 hover:bg-surface-100 transition-all duration-150 hover:text-red-600 {dark ? 'hover:bg-surface-700 text-surface-400 hover:text-red-400' : ''}"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /></svg>
Sign out
</button>
</div> </div>
</aside> </aside>
+58 -6
View File
@@ -1,13 +1,49 @@
const BASE = "/api"; 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 = {}) { 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 { try {
const r = await fetch(BASE + path, opts); const r = await fetch(BASE + path, { ...opts, headers });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
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(); return r.json();
} catch (e) { } catch (e) {
console.error(`API ${opts.method || "GET"} ${path}:`, e); console.error(`API ${opts.method || "GET"} ${path}:`, e);
return null; throw e;
} }
} }
@@ -15,8 +51,8 @@ export function get(path) {
return request(path); return request(path);
} }
export function post(path) { export function post(path, data) {
return request(path, { method: "POST" }); return request(path, { method: "POST", json: data });
} }
export function del(path) { export function del(path) {
@@ -24,10 +60,26 @@ export function del(path) {
} }
export async function upload(path, file) { export async function upload(path, file) {
const headers = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
const form = new FormData(); const form = new FormData();
form.append("file", file); form.append("file", file);
return request(`/files/upload?path=${encodeURIComponent(path)}`, {
const r = await fetch(`${BASE}/files/upload?path=${encodeURIComponent(path)}`, {
method: "POST", method: "POST",
headers,
body: form, 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");
} }
+145
View File
@@ -0,0 +1,145 @@
<script>
import { login, setToken } from "../lib/api.js";
import { fade, fly } from "svelte/transition";
let username = $state("");
let password = $state("");
let totp_code = $state("");
let error = $state("");
let loading = $state(false);
let require2FA = $state(false);
async function handleSubmit(e) {
e.preventDefault();
loading = true;
error = "";
try {
const payload = { username, password };
if (require2FA) payload.totp_code = totp_code;
const res = await login(payload); // api.js helper sends JSON
setToken(res.access_token);
// Determine if we need to reload or just update state.
// Reload is safest to clear any stale state.
window.location.reload();
} catch (e) {
if (e.status === 403 && e.body?.detail === "2FA code required") {
require2FA = true;
// Clear error if any
error = "";
} else {
error = e.body?.detail || "Login failed. Please check your credentials.";
// Reset 2FA state on general failure (maybe password was wrong this time)
if (!require2FA) {
password = "";
}
}
} finally {
loading = false;
}
}
</script>
<div class="flex min-h-screen items-center justify-center bg-surface-50 dark:bg-surface-950 px-4">
<div class="w-full max-w-sm space-y-8 bg-white dark:bg-surface-900 p-8 rounded-2xl shadow-xl border border-surface-200 dark:border-surface-800" in:fly={{ y: 20, duration: 400 }}>
<div class="text-center">
<div class="mx-auto h-12 w-12 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center shadow-lg mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h2 class="text-2xl font-bold tracking-tight text-surface-900 dark:text-white">Sign in to NAS</h2>
<p class="mt-2 text-sm text-surface-600 dark:text-surface-400">
Enter your credentials to access the dashboard
</p>
</div>
<form class="mt-8 space-y-6" onsubmit={handleSubmit}>
<div class="space-y-4">
{#if !require2FA}
<div>
<label for="username" class="sr-only">Username</label>
<input
id="username"
name="username"
type="text"
required
bind:value={username}
class="relative block w-full rounded-lg border-0 py-2.5 px-3 text-surface-900 dark:text-white ring-1 ring-inset ring-surface-300 dark:ring-surface-700 placeholder:text-surface-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-primary-600 dark:bg-surface-800 sm:text-sm sm:leading-6"
placeholder="Username"
/>
</div>
<div>
<label for="password" class="sr-only">Password</label>
<input
id="password"
name="password"
type="password"
required
bind:value={password}
class="relative block w-full rounded-lg border-0 py-2.5 px-3 text-surface-900 dark:text-white ring-1 ring-inset ring-surface-300 dark:ring-surface-700 placeholder:text-surface-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-primary-600 dark:bg-surface-800 sm:text-sm sm:leading-6"
placeholder="Password"
/>
</div>
{:else}
<div in:fade>
<label for="totp" class="block text-sm font-medium leading-6 text-surface-900 dark:text-white text-center mb-2">Authenticator Code</label>
<input
id="totp"
name="totp"
type="text"
inputmode="numeric"
pattern="[0-9]*"
autocomplete="one-time-code"
required
autofocus
bind:value={totp_code}
class="block w-full text-center tracking-[0.5em] text-2xl font-mono rounded-lg border-0 py-3 text-surface-900 dark:text-white ring-1 ring-inset ring-surface-300 dark:ring-surface-700 placeholder:text-surface-400 focus:ring-2 focus:ring-inset focus:ring-primary-600 dark:bg-surface-800"
placeholder="000000"
maxlength="6"
/>
<p class="mt-4 text-center text-sm text-surface-500">
<button type="button" class="text-primary-500 hover:text-primary-400" onclick={() => require2FA = false}>
Back to credentials
</button>
</p>
</div>
{/if}
</div>
{#if error}
<div class="rounded-md bg-red-50 dark:bg-red-900/30 p-3" in:fade>
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800 dark:text-red-200">{error}</h3>
</div>
</div>
</div>
{/if}
<div>
<button
type="submit"
disabled={loading}
class="flex w-full justify-center rounded-lg bg-primary-600 px-3 py-2.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-primary-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 disabled:opacity-70 transition-all active:scale-[0.98]"
>
{#if loading}
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{/if}
{require2FA ? "Verify Code" : "Sign in"}
</button>
</div>
</form>
</div>
</div>