diff --git a/main.py b/main.py index a5e6a4e..d9cd49c 100644 --- a/main.py +++ b/main.py @@ -11,9 +11,12 @@ 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 passlib.context import CryptContext from pydantic import BaseModel from cryptography.fernet import Fernet +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + WEB_PASSWORD = os.getenv("WEB_PASSWORD", "admin") ENCRYPTION_KEY = os.getenv("ENCRYPTION_KEY") FIREWALL_HOST_IP = os.getenv("FIREWALL_HOST_IP") @@ -33,9 +36,35 @@ except ValueError: app = FastAPI() app.add_middleware(SessionMiddleware, secret_key=ENCRYPTION_KEY) +def get_password_hash(password): + return pwd_context.hash(password) + +def verify_password(plain_password, hashed_password): + return pwd_context.verify(plain_password, hashed_password) + def init_db(): conn = sqlite3.connect(DB_PATH) c = conn.cursor() + + # Create users table + c.execute(''' + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + hashed_password TEXT NOT NULL, + is_admin INTEGER DEFAULT 0 + ) + ''') + + # Check if instances table has user_id, if not, add it (simple migration) + c.execute("PRAGMA table_info(instances)") + columns = [col[1] for col in c.fetchall()] + if "user_id" not in columns: + print("Migrating instances table to include user_id...") + # SQLite doesn't support adding a column with a foreign key constraint easily, + # so we'll just add the column for now. + c.execute('ALTER TABLE instances ADD COLUMN user_id INTEGER') + c.execute(''' CREATE TABLE IF NOT EXISTS instances ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -44,27 +73,58 @@ def init_db(): port INTEGER NOT NULL, encrypted_secret TEXT NOT NULL, last_status TEXT DEFAULT 'Unknown', - last_checked REAL DEFAULT 0 + last_checked REAL DEFAULT 0, + user_id INTEGER, + FOREIGN KEY (user_id) REFERENCES users (id) ) ''') + + # Create default admin if no users exist + c.execute('SELECT COUNT(*) FROM users') + if c.fetchone()[0] == 0: + print("Creating default admin user...") + admin_hash = get_password_hash(WEB_PASSWORD) + c.execute('INSERT INTO users (username, hashed_password, is_admin) VALUES (?, ?, ?)', + ('admin', admin_hash, 1)) + + # Associate existing instances with the new admin user + admin_id = c.lastrowid + c.execute('UPDATE instances SET user_id = ? WHERE user_id IS NULL', (admin_id,)) + conn.commit() conn.close() init_db() def is_authenticated(request: Request): - if not request.session.get("authenticated"): + if not request.session.get("user_id"): raise HTTPException(status_code=401, detail="Not authenticated") return True +def is_admin(request: Request): + if not request.session.get("is_admin"): + raise HTTPException(status_code=403, detail="Admin access required") + return True + @app.post("/api/login") async def login(request: Request): data = await request.json() + username = data.get("username") 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") + + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + c = conn.cursor() + c.execute('SELECT * FROM users WHERE username = ?', (username,)) + user = c.fetchone() + conn.close() + + if user and verify_password(password, user['hashed_password']): + request.session["user_id"] = user['id'] + request.session["username"] = user['username'] + request.session["is_admin"] = bool(user['is_admin']) + return {"status": "ok", "user": {"username": user['username'], "is_admin": bool(user['is_admin'])}} + raise HTTPException(status_code=401, detail="Invalid username or password") @app.get("/api/logout") async def logout(request: Request): @@ -73,7 +133,91 @@ async def logout(request: Request): @app.get("/api/auth/status") async def auth_status(request: Request): - return {"authenticated": request.session.get("authenticated", False)} + if request.session.get("user_id"): + return { + "authenticated": True, + "user": { + "username": request.session.get("username"), + "is_admin": request.session.get("is_admin") + } + } + return {"authenticated": False} + +# User Management (Admin Only) +@app.get("/api/users", dependencies=[Depends(is_authenticated), Depends(is_admin)]) +def get_users(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + c = conn.cursor() + c.execute('SELECT id, username, is_admin FROM users') + users = [dict(row) for row in c.fetchall()] + conn.close() + return users + +@app.post("/api/users", dependencies=[Depends(is_authenticated), Depends(is_admin)]) +async def create_user(request: Request): + data = await request.json() + username = data.get("username") + password = data.get("password") + is_admin = 1 if data.get("is_admin") else 0 + + if not username or not password: + raise HTTPException(status_code=400, detail="Username and password required") + + hashed = get_password_hash(password) + try: + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute('INSERT INTO users (username, hashed_password, is_admin) VALUES (?, ?, ?)', + (username, hashed, is_admin)) + conn.commit() + conn.close() + except sqlite3.IntegrityError: + raise HTTPException(status_code=400, detail="Username already exists") + return {"status": "ok"} + +@app.delete("/api/users/{id}", dependencies=[Depends(is_authenticated), Depends(is_admin)]) +def delete_user(id: int, request: Request): + if id == request.session.get("user_id"): + raise HTTPException(status_code=400, detail="Cannot delete yourself") + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute('DELETE FROM users WHERE id=?', (id,)) + # Also delete their instances? Let's say yes for cleanliness. + c.execute('DELETE FROM instances WHERE user_id=?', (id,)) + conn.commit() + conn.close() + return {"status": "ok"} + +@app.put("/api/users/{id}/password", dependencies=[Depends(is_authenticated), Depends(is_admin)]) +async def admin_reset_password(id: int, request: Request): + data = await request.json() + new_password = data.get("password") + if not new_password: + raise HTTPException(status_code=400, detail="New password required") + hashed = get_password_hash(new_password) + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute('UPDATE users SET hashed_password = ? WHERE id = ?', (hashed, id)) + conn.commit() + conn.close() + return {"status": "ok"} + +# Own Password Change (All Users) +@app.put("/api/users/me/password", dependencies=[Depends(is_authenticated)]) +async def change_own_password(request: Request): + data = await request.json() + new_password = data.get("password") + if not new_password: + raise HTTPException(status_code=400, detail="New password required") + user_id = request.session.get("user_id") + hashed = get_password_hash(new_password) + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute('UPDATE users SET hashed_password = ? WHERE id = ?', (hashed, user_id)) + conn.commit() + conn.close() + return {"status": "ok"} class InstanceCreate(BaseModel): name: str @@ -153,25 +297,27 @@ async def startup_event(): asyncio.create_task(poll_instances()) @app.get("/api/instances", dependencies=[Depends(is_authenticated)]) -def get_instances(): +def get_instances(request: Request): + user_id = request.session.get("user_id") conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row c = conn.cursor() - c.execute('SELECT id, name, ip, port, last_status, last_checked FROM instances') + c.execute('SELECT id, name, ip, port, last_status, last_checked FROM instances WHERE user_id = ?', (user_id,)) instances = [dict(row) for row in c.fetchall()] conn.close() return instances @app.post("/api/instances", dependencies=[Depends(is_authenticated)]) -def create_instance(inst: InstanceCreate): +def create_instance(inst: InstanceCreate, request: Request): + user_id = request.session.get("user_id") cleaned = clean_secret(inst.secret) encrypted = fernet.encrypt(cleaned.encode()).decode() conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.execute(''' - INSERT INTO instances (name, ip, port, encrypted_secret) - VALUES (?, ?, ?, ?) - ''', (inst.name, inst.ip, inst.port, encrypted)) + INSERT INTO instances (name, ip, port, encrypted_secret, user_id) + VALUES (?, ?, ?, ?, ?) + ''', (inst.name, inst.ip, inst.port, encrypted, user_id)) conn.commit() conn.close() return {"status": "ok"} @@ -181,10 +327,11 @@ def get_config(): return {"firewall_host_ip": FIREWALL_HOST_IP} @app.delete("/api/instances/{id}", dependencies=[Depends(is_authenticated)]) -def delete_instance(id: int): +def delete_instance(id: int, request: Request): + user_id = request.session.get("user_id") conn = sqlite3.connect(DB_PATH) c = conn.cursor() - c.execute('DELETE FROM instances WHERE id=?', (id,)) + c.execute('DELETE FROM instances WHERE id=? AND user_id=?', (id, user_id)) conn.commit() conn.close() return {"status": "ok"} diff --git a/requirements.txt b/requirements.txt index 4a6d577..bec8007 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ pyotp==2.9.0 httpx==0.25.1 pydantic==2.4.2 itsdangerous==2.1.2 +passlib[bcrypt]==1.7.4 diff --git a/static/app.js b/static/app.js index d519f23..81a3ede 100644 --- a/static/app.js +++ b/static/app.js @@ -113,14 +113,18 @@ document.addEventListener('DOMContentLoaded', () => { loginForm.addEventListener('submit', async (e) => { e.preventDefault(); + const username = document.getElementById('login-username').value; 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 }) + body: JSON.stringify({ username, password }) }); if (res.ok) { + const data = await res.json(); + currentUser = data.user; + setupUIForUser(); hideLogin(); loginError.style.display = 'none'; document.getElementById('login-password').value = ''; @@ -134,15 +138,66 @@ document.addEventListener('DOMContentLoaded', () => { } }); + const setupUIForUser = () => { + if (currentUser.is_admin) { + navAdmin.style.display = 'block'; + } else { + navAdmin.style.display = 'none'; + } + }; + logoutBtn.addEventListener('click', async () => { try { await fetch('/api/logout'); + currentUser = null; showLogin(); } catch (e) { console.error('Logout error', e); } }); + createUserForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const username = document.getElementById('new-username').value; + const password = document.getElementById('new-password').value; + const is_admin = document.getElementById('new-is-admin').checked; + try { + const res = await fetch('/api/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password, is_admin }) + }); + if (res.ok) { + createUserForm.reset(); + fetchUsers(); + } else { + const data = await res.json(); + alert(data.detail || 'Failed to create user'); + } + } catch (e) { + console.error('Error creating user', e); + } + }); + + changePasswordForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const password = document.getElementById('profile-new-password').value; + try { + const res = await fetch('/api/users/me/password', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }) + }); + if (res.ok) { + changePasswordForm.reset(); + profileSuccess.style.display = 'block'; + setTimeout(() => profileSuccess.style.display = 'none', 3000); + } + } catch (e) { + console.error('Error changing password', e); + } + }); + refreshBtn.addEventListener('click', fetchInstances); window.deleteInstance = async (id) => { diff --git a/static/favicon.png b/static/favicon.png new file mode 100644 index 0000000..022b363 Binary files /dev/null and b/static/favicon.png differ diff --git a/static/index.html b/static/index.html index 3214f2e..1c79eb2 100644 --- a/static/index.html +++ b/static/index.html @@ -5,6 +5,7 @@ XIVLauncher Remote OTP + @@ -26,8 +27,15 @@ + +
-
+
+

Add Instance

@@ -89,6 +97,42 @@
+ +
+
+

User Management

+ +
+ +
+
+ +
+
+ +
+ + +
+ +
+
+
+ +
+
+

My Profile

+

Change your account password below.

+
+
+ + +
+ +
+ +
+
@@ -99,16 +143,20 @@ -

Login Required

+

Login

+
+ + +
- + diff --git a/static/style.css b/static/style.css index 97104c0..a8c18d2 100644 --- a/static/style.css +++ b/static/style.css @@ -61,6 +61,44 @@ header { box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3); } +.main-nav { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid var(--border-color); + padding-bottom: 1rem; +} + +.nav-item { + background: transparent; + border: none; + color: var(--text-muted); + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + padding: 0.5rem 1rem; + border-radius: 6px; + transition: all 0.2s; +} + +.nav-item:hover { + color: var(--text-main); + background-color: rgba(255, 255, 255, 0.05); +} + +.nav-item.active { + color: var(--primary); + background-color: rgba(59, 130, 246, 0.1); +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + .login-card .logo { justify-content: center; margin-bottom: 2rem; @@ -335,3 +373,49 @@ input:focus { .btn-copy.copied { color: var(--status-online); } + +.users-list { + margin-top: 2rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.user-item { + background-color: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 1rem; + display: flex; + justify-content: space-between; + align-items: center; +} + +.user-info h3 { + font-size: 1rem; + margin-bottom: 0.25rem; +} + +.user-actions { + display: flex; + gap: 0.5rem; +} + +.checkbox-group { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text-muted); + font-size: 0.875rem; +} + +.checkbox-group input { + width: auto; +} + +.success-msg { + color: var(--status-online); + text-align: center; + margin-top: 1rem; + font-size: 0.875rem; +}