diff --git a/main.py b/main.py index eb1bb02..a5e6a4e 100644 --- a/main.py +++ b/main.py @@ -7,9 +7,10 @@ import httpx import base64 import secrets import urllib.parse -from fastapi import FastAPI, Request -from fastapi.responses import Response, FileResponse +from fastapi import FastAPI, Request, Depends, HTTPException +from fastapi.responses import Response, FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles +from starlette.middleware.sessions import SessionMiddleware from pydantic import BaseModel from cryptography.fernet import Fernet @@ -30,6 +31,7 @@ except ValueError: exit(1) app = FastAPI() +app.add_middleware(SessionMiddleware, secret_key=ENCRYPTION_KEY) def init_db(): conn = sqlite3.connect(DB_PATH) @@ -50,21 +52,28 @@ def init_db(): init_db() -@app.middleware("http") -async def basic_auth_middleware(request: Request, call_next): - if request.url.path.startswith("/api/") or request.url.path == "/" or request.url.path.startswith("/static/"): - auth = request.headers.get("Authorization") - if not auth or not auth.startswith("Basic "): - return Response(status_code=401, headers={"WWW-Authenticate": 'Basic realm="Login Required"'}, content="Unauthorized") - try: - decoded = base64.b64decode(auth[6:]).decode() - username, password = decoded.split(":", 1) - # Accept any username, just check password - if not secrets.compare_digest(password, WEB_PASSWORD): - return Response(status_code=401, headers={"WWW-Authenticate": 'Basic realm="Login Required"'}, content="Unauthorized") - except: - return Response(status_code=401, headers={"WWW-Authenticate": 'Basic realm="Login Required"'}, content="Unauthorized") - return await call_next(request) +def is_authenticated(request: Request): + if not request.session.get("authenticated"): + raise HTTPException(status_code=401, detail="Not authenticated") + return True + +@app.post("/api/login") +async def login(request: Request): + data = await request.json() + password = data.get("password") + if secrets.compare_digest(password, WEB_PASSWORD): + request.session["authenticated"] = True + return {"status": "ok"} + raise HTTPException(status_code=401, detail="Invalid password") + +@app.get("/api/logout") +async def logout(request: Request): + request.session.clear() + return {"status": "ok"} + +@app.get("/api/auth/status") +async def auth_status(request: Request): + return {"authenticated": request.session.get("authenticated", False)} class InstanceCreate(BaseModel): name: str @@ -143,7 +152,7 @@ async def poll_instances(): async def startup_event(): asyncio.create_task(poll_instances()) -@app.get("/api/instances") +@app.get("/api/instances", dependencies=[Depends(is_authenticated)]) def get_instances(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row @@ -153,7 +162,7 @@ def get_instances(): conn.close() return instances -@app.post("/api/instances") +@app.post("/api/instances", dependencies=[Depends(is_authenticated)]) def create_instance(inst: InstanceCreate): cleaned = clean_secret(inst.secret) encrypted = fernet.encrypt(cleaned.encode()).decode() @@ -167,11 +176,11 @@ def create_instance(inst: InstanceCreate): conn.close() return {"status": "ok"} -@app.get("/api/config") +@app.get("/api/config", dependencies=[Depends(is_authenticated)]) def get_config(): return {"firewall_host_ip": FIREWALL_HOST_IP} -@app.delete("/api/instances/{id}") +@app.delete("/api/instances/{id}", dependencies=[Depends(is_authenticated)]) def delete_instance(id: int): conn = sqlite3.connect(DB_PATH) c = conn.cursor() diff --git a/requirements.txt b/requirements.txt index 7fa07f6..4a6d577 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ cryptography==41.0.5 pyotp==2.9.0 httpx==0.25.1 pydantic==2.4.2 +itsdangerous==2.1.2 diff --git a/static/app.js b/static/app.js index ce1c9c7..d519f23 100644 --- a/static/app.js +++ b/static/app.js @@ -101,6 +101,8 @@ document.addEventListener('DOMContentLoaded', () => { addForm.reset(); document.getElementById('port').value = "4646"; fetchInstances(); + } else if (res.status === 401) { + showLogin(); } else { alert('Failed to add instance'); } @@ -109,12 +111,45 @@ document.addEventListener('DOMContentLoaded', () => { } }); + loginForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const password = document.getElementById('login-password').value; + try { + const res = await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }) + }); + if (res.ok) { + hideLogin(); + loginError.style.display = 'none'; + document.getElementById('login-password').value = ''; + fetchConfig(); + fetchInstances(); + } else { + loginError.style.display = 'block'; + } + } catch (e) { + console.error('Login error', e); + } + }); + + logoutBtn.addEventListener('click', async () => { + try { + await fetch('/api/logout'); + showLogin(); + } catch (e) { + console.error('Logout error', e); + } + }); + refreshBtn.addEventListener('click', fetchInstances); window.deleteInstance = async (id) => { if (!confirm('Are you sure you want to delete this instance?')) return; try { const res = await fetch(`/api/instances/${id}`, { method: 'DELETE' }); + if (res.status === 401) return showLogin(); if (res.ok) { fetchInstances(); } diff --git a/static/index.html b/static/index.html index ea535ab..3214f2e 100644 --- a/static/index.html +++ b/static/index.html @@ -17,6 +17,13 @@