implement user authentication, role-based access control, and multi-user instance isolation

This commit is contained in:
CPTN Cosmo 2026-04-18 17:00:13 +02:00
parent 844879d301
commit bb7053b01e
6 changed files with 354 additions and 19 deletions

177
main.py
View file

@ -11,9 +11,12 @@ from fastapi import FastAPI, Request, Depends, HTTPException
from fastapi.responses import Response, FileResponse, JSONResponse from fastapi.responses import Response, FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from passlib.context import CryptContext
from pydantic import BaseModel from pydantic import BaseModel
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
WEB_PASSWORD = os.getenv("WEB_PASSWORD", "admin") WEB_PASSWORD = os.getenv("WEB_PASSWORD", "admin")
ENCRYPTION_KEY = os.getenv("ENCRYPTION_KEY") ENCRYPTION_KEY = os.getenv("ENCRYPTION_KEY")
FIREWALL_HOST_IP = os.getenv("FIREWALL_HOST_IP") FIREWALL_HOST_IP = os.getenv("FIREWALL_HOST_IP")
@ -33,9 +36,35 @@ except ValueError:
app = FastAPI() app = FastAPI()
app.add_middleware(SessionMiddleware, secret_key=ENCRYPTION_KEY) 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(): def init_db():
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
c = conn.cursor() 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(''' c.execute('''
CREATE TABLE IF NOT EXISTS instances ( CREATE TABLE IF NOT EXISTS instances (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -44,27 +73,58 @@ def init_db():
port INTEGER NOT NULL, port INTEGER NOT NULL,
encrypted_secret TEXT NOT NULL, encrypted_secret TEXT NOT NULL,
last_status TEXT DEFAULT 'Unknown', 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.commit()
conn.close() conn.close()
init_db() init_db()
def is_authenticated(request: Request): 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") raise HTTPException(status_code=401, detail="Not authenticated")
return True 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") @app.post("/api/login")
async def login(request: Request): async def login(request: Request):
data = await request.json() data = await request.json()
username = data.get("username")
password = data.get("password") password = data.get("password")
if secrets.compare_digest(password, WEB_PASSWORD):
request.session["authenticated"] = True conn = sqlite3.connect(DB_PATH)
return {"status": "ok"} conn.row_factory = sqlite3.Row
raise HTTPException(status_code=401, detail="Invalid password") 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") @app.get("/api/logout")
async def logout(request: Request): async def logout(request: Request):
@ -73,7 +133,91 @@ async def logout(request: Request):
@app.get("/api/auth/status") @app.get("/api/auth/status")
async def auth_status(request: Request): 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): class InstanceCreate(BaseModel):
name: str name: str
@ -153,25 +297,27 @@ async def startup_event():
asyncio.create_task(poll_instances()) asyncio.create_task(poll_instances())
@app.get("/api/instances", dependencies=[Depends(is_authenticated)]) @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 = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
c = conn.cursor() 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()] instances = [dict(row) for row in c.fetchall()]
conn.close() conn.close()
return instances return instances
@app.post("/api/instances", dependencies=[Depends(is_authenticated)]) @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) cleaned = clean_secret(inst.secret)
encrypted = fernet.encrypt(cleaned.encode()).decode() encrypted = fernet.encrypt(cleaned.encode()).decode()
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
c = conn.cursor() c = conn.cursor()
c.execute(''' c.execute('''
INSERT INTO instances (name, ip, port, encrypted_secret) INSERT INTO instances (name, ip, port, encrypted_secret, user_id)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
''', (inst.name, inst.ip, inst.port, encrypted)) ''', (inst.name, inst.ip, inst.port, encrypted, user_id))
conn.commit() conn.commit()
conn.close() conn.close()
return {"status": "ok"} return {"status": "ok"}
@ -181,10 +327,11 @@ def get_config():
return {"firewall_host_ip": FIREWALL_HOST_IP} return {"firewall_host_ip": FIREWALL_HOST_IP}
@app.delete("/api/instances/{id}", dependencies=[Depends(is_authenticated)]) @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) conn = sqlite3.connect(DB_PATH)
c = conn.cursor() 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.commit()
conn.close() conn.close()
return {"status": "ok"} return {"status": "ok"}

View file

@ -5,3 +5,4 @@ pyotp==2.9.0
httpx==0.25.1 httpx==0.25.1
pydantic==2.4.2 pydantic==2.4.2
itsdangerous==2.1.2 itsdangerous==2.1.2
passlib[bcrypt]==1.7.4

View file

@ -113,14 +113,18 @@ document.addEventListener('DOMContentLoaded', () => {
loginForm.addEventListener('submit', async (e) => { loginForm.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const username = document.getElementById('login-username').value;
const password = document.getElementById('login-password').value; const password = document.getElementById('login-password').value;
try { try {
const res = await fetch('/api/login', { const res = await fetch('/api/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }) body: JSON.stringify({ username, password })
}); });
if (res.ok) { if (res.ok) {
const data = await res.json();
currentUser = data.user;
setupUIForUser();
hideLogin(); hideLogin();
loginError.style.display = 'none'; loginError.style.display = 'none';
document.getElementById('login-password').value = ''; 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 () => { logoutBtn.addEventListener('click', async () => {
try { try {
await fetch('/api/logout'); await fetch('/api/logout');
currentUser = null;
showLogin(); showLogin();
} catch (e) { } catch (e) {
console.error('Logout error', 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); refreshBtn.addEventListener('click', fetchInstances);
window.deleteInstance = async (id) => { window.deleteInstance = async (id) => {

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 KiB

View file

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>XIVLauncher Remote OTP</title> <title>XIVLauncher Remote OTP</title>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<link rel="icon" type="image/png" href="/static/favicon.png">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
@ -26,7 +27,14 @@
</button> </button>
</header> </header>
<nav id="main-nav" class="main-nav" style="display: none;">
<button class="nav-item active" data-tab="instances">Instances</button>
<button class="nav-item" id="nav-admin" data-tab="admin" style="display: none;">Admin</button>
<button class="nav-item" data-tab="profile">Profile</button>
</nav>
<main> <main>
<div id="instances-section" class="tab-content active">
<div class="card add-instance-card"> <div class="card add-instance-card">
<h2>Add Instance</h2> <h2>Add Instance</h2>
<form id="add-form"> <form id="add-form">
@ -89,6 +97,42 @@
<!-- Instances injected via JS --> <!-- Instances injected via JS -->
</div> </div>
</div> </div>
<div id="admin-section" class="tab-content">
<div class="card user-management-card">
<h2>User Management</h2>
<form id="create-user-form" class="form-row">
<div class="form-group">
<input type="text" id="new-username" placeholder="Username" required>
</div>
<div class="form-group">
<input type="password" id="new-password" placeholder="Password" required>
</div>
<div class="form-group checkbox-group">
<label><input type="checkbox" id="new-is-admin"> Admin</label>
</div>
<button type="submit" class="btn btn-primary">Create User</button>
</form>
<div id="users-list" class="users-list">
<!-- Users injected via JS -->
</div>
</div>
</div>
<div id="profile-section" class="tab-content">
<div class="card profile-card">
<h2>My Profile</h2>
<p class="helper-text">Change your account password below.</p>
<form id="change-password-form">
<div class="form-group">
<label for="profile-new-password">New Password</label>
<input type="password" id="profile-new-password" placeholder="Enter new password" required>
</div>
<button type="submit" class="btn btn-primary">Update Password</button>
</form>
<div id="profile-success" class="success-msg" style="display: none;">Password updated successfully!</div>
</div>
</div>
</main> </main>
</div> </div>
@ -99,16 +143,20 @@
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect> <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> <path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg> </svg>
<h2>Login Required</h2> <h2>Login</h2>
</div> </div>
<form id="login-form"> <form id="login-form">
<div class="form-group">
<label for="login-username">Username</label>
<input type="text" id="login-username" placeholder="Enter your username" required autocomplete="username">
</div>
<div class="form-group"> <div class="form-group">
<label for="login-password">Password</label> <label for="login-password">Password</label>
<input type="password" id="login-password" placeholder="Enter your password" required autocomplete="current-password"> <input type="password" id="login-password" placeholder="Enter your password" required autocomplete="current-password">
</div> </div>
<button type="submit" class="btn btn-primary">Login</button> <button type="submit" class="btn btn-primary">Login</button>
</form> </form>
<div id="login-error" class="login-error" style="display: none;">Invalid password</div> <div id="login-error" class="login-error" style="display: none;">Invalid username or password</div>
</div> </div>
</div> </div>

View file

@ -61,6 +61,44 @@ header {
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3); 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 { .login-card .logo {
justify-content: center; justify-content: center;
margin-bottom: 2rem; margin-bottom: 2rem;
@ -335,3 +373,49 @@ input:focus {
.btn-copy.copied { .btn-copy.copied {
color: var(--status-online); 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;
}