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 base64
import secrets import secrets
import urllib.parse import urllib.parse
from fastapi import FastAPI, Request from fastapi import FastAPI, Request, Depends, HTTPException
from fastapi.responses import Response, FileResponse from fastapi.responses import Response, FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from pydantic import BaseModel from pydantic import BaseModel
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
@ -30,6 +31,7 @@ except ValueError:
exit(1) exit(1)
app = FastAPI() app = FastAPI()
app.add_middleware(SessionMiddleware, secret_key=ENCRYPTION_KEY)
def init_db(): def init_db():
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
@ -50,21 +52,28 @@ def init_db():
init_db() init_db()
@app.middleware("http") def is_authenticated(request: Request):
async def basic_auth_middleware(request: Request, call_next): if not request.session.get("authenticated"):
if request.url.path.startswith("/api/") or request.url.path == "/" or request.url.path.startswith("/static/"): raise HTTPException(status_code=401, detail="Not authenticated")
auth = request.headers.get("Authorization") return True
if not auth or not auth.startswith("Basic "):
return Response(status_code=401, headers={"WWW-Authenticate": 'Basic realm="Login Required"'}, content="Unauthorized") @app.post("/api/login")
try: async def login(request: Request):
decoded = base64.b64decode(auth[6:]).decode() data = await request.json()
username, password = decoded.split(":", 1) password = data.get("password")
# Accept any username, just check password if secrets.compare_digest(password, WEB_PASSWORD):
if not secrets.compare_digest(password, WEB_PASSWORD): request.session["authenticated"] = True
return Response(status_code=401, headers={"WWW-Authenticate": 'Basic realm="Login Required"'}, content="Unauthorized") return {"status": "ok"}
except: raise HTTPException(status_code=401, detail="Invalid password")
return Response(status_code=401, headers={"WWW-Authenticate": 'Basic realm="Login Required"'}, content="Unauthorized")
return await call_next(request) @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): class InstanceCreate(BaseModel):
name: str name: str
@ -143,7 +152,7 @@ async def poll_instances():
async def startup_event(): async def startup_event():
asyncio.create_task(poll_instances()) asyncio.create_task(poll_instances())
@app.get("/api/instances") @app.get("/api/instances", dependencies=[Depends(is_authenticated)])
def get_instances(): def get_instances():
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
@ -153,7 +162,7 @@ def get_instances():
conn.close() conn.close()
return instances return instances
@app.post("/api/instances") @app.post("/api/instances", dependencies=[Depends(is_authenticated)])
def create_instance(inst: InstanceCreate): def create_instance(inst: InstanceCreate):
cleaned = clean_secret(inst.secret) cleaned = clean_secret(inst.secret)
encrypted = fernet.encrypt(cleaned.encode()).decode() encrypted = fernet.encrypt(cleaned.encode()).decode()
@ -167,11 +176,11 @@ def create_instance(inst: InstanceCreate):
conn.close() conn.close()
return {"status": "ok"} return {"status": "ok"}
@app.get("/api/config") @app.get("/api/config", dependencies=[Depends(is_authenticated)])
def get_config(): def get_config():
return {"firewall_host_ip": FIREWALL_HOST_IP} 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): def delete_instance(id: int):
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
c = conn.cursor() c = conn.cursor()

View file

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

View file

@ -101,6 +101,8 @@ document.addEventListener('DOMContentLoaded', () => {
addForm.reset(); addForm.reset();
document.getElementById('port').value = "4646"; document.getElementById('port').value = "4646";
fetchInstances(); fetchInstances();
} else if (res.status === 401) {
showLogin();
} else { } else {
alert('Failed to add instance'); 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); refreshBtn.addEventListener('click', fetchInstances);
window.deleteInstance = async (id) => { window.deleteInstance = async (id) => {
if (!confirm('Are you sure you want to delete this instance?')) return; if (!confirm('Are you sure you want to delete this instance?')) return;
try { try {
const res = await fetch(`/api/instances/${id}`, { method: 'DELETE' }); const res = await fetch(`/api/instances/${id}`, { method: 'DELETE' });
if (res.status === 401) return showLogin();
if (res.ok) { if (res.ok) {
fetchInstances(); fetchInstances();
} }

View file

@ -17,6 +17,13 @@
</svg> </svg>
<h1>XIVLauncher Remote OTP</h1> <h1>XIVLauncher Remote OTP</h1>
</div> </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> </header>
<main> <main>
@ -84,6 +91,27 @@
</div> </div>
</main> </main>
</div> </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> <script src="/static/app.js"></script>
</body> </body>
</html> </html>

View file

@ -33,6 +33,44 @@ body {
header { header {
margin-bottom: 2rem; 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 { .logo {