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(); } -
+ Enter your credentials to access the dashboard +
+