Phase 7: Auth & Security upgrade (JWT, 2FA, Login UI)
This commit is contained in:
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -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"}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -5,18 +5,71 @@
|
|||||||
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">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
{:else if !authorized}
|
||||||
|
<Login />
|
||||||
|
{:else}
|
||||||
|
<div class="flex h-screen overflow-hidden bg-surface-50 {dark ? 'dark bg-surface-900' : ''}">
|
||||||
|
<Sidebar bind:page {dark} {toggleTheme} {logout} />
|
||||||
<main class="flex-1 overflow-auto">
|
<main class="flex-1 overflow-auto">
|
||||||
<div class="max-w-[1400px] mx-auto p-6 lg:p-8">
|
<div class="max-w-[1400px] mx-auto p-6 lg:p-8">
|
||||||
{#if page === "dashboard"}
|
{#if page === "dashboard"}
|
||||||
@@ -32,4 +85,5 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user