replace Basic Auth with session-based authentication and a login overlay

This commit is contained in:
CPTN Cosmo 2026-04-18 16:55:44 +02:00
parent 2304717de5
commit 844879d301
5 changed files with 132 additions and 21 deletions

51
main.py
View file

@ -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()

View file

@ -4,3 +4,4 @@ cryptography==41.0.5
pyotp==2.9.0
httpx==0.25.1
pydantic==2.4.2
itsdangerous==2.1.2

View file

@ -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();
}

View file

@ -17,6 +17,13 @@
</svg>
<h1>XIVLauncher Remote OTP</h1>
</div>
<button id="logout-btn" class="btn btn-icon btn-logout" title="Logout" style="display: none;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
</button>
</header>
<main>
@ -84,6 +91,27 @@
</div>
</main>
</div>
<div id="login-overlay" class="login-overlay">
<div class="login-card">
<div class="logo">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
<h2>Login Required</h2>
</div>
<form id="login-form">
<div class="form-group">
<label for="login-password">Password</label>
<input type="password" id="login-password" placeholder="Enter your password" required autocomplete="current-password">
</div>
<button type="submit" class="btn btn-primary">Login</button>
</form>
<div id="login-error" class="login-error" style="display: none;">Invalid password</div>
</div>
</div>
<script src="/static/app.js"></script>
</body>
</html>

View file

@ -33,6 +33,44 @@ body {
header {
margin-bottom: 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.login-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--bg-color);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.login-card {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 2rem;
width: 100%;
max-width: 400px;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3);
}
.login-card .logo {
justify-content: center;
margin-bottom: 2rem;
}
.login-error {
color: var(--status-offline);
font-size: 0.875rem;
margin-top: 1rem;
text-align: center;
}
.logo {