Add registration control, admin user management, and security hardening

- Registration auto-closes after first user; admin can toggle from /admin/users
- Admin user management view with user list and delete
- Password confirmation on registration form
- Password change in Settings (PUT /api/auth/password)
- Session cookie hardening: HttpOnly, SameSite=Lax, optional Secure flag
- Startup warning when SECRET_KEY is default
- Production deployment docs: reverse proxy, rate limiting, CSP headers
- Fix assist prompt to preserve markdown headings in target sections
- Simplify prod compose networking

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 17:49:22 -05:00
parent f2496916f9
commit f77b029943
16 changed files with 809 additions and 60 deletions
+46
View File
@@ -3,6 +3,12 @@ import json
from quart import Blueprint, Response, jsonify, request
from fabledassistant.auth import admin_required, login_required, get_current_user_id
from fabledassistant.services.auth import (
delete_user,
is_registration_open,
list_users,
set_registration_open,
)
from fabledassistant.services.backup import (
export_full_backup,
export_user_backup,
@@ -45,3 +51,43 @@ async def restore():
stats = await restore_full_backup(data)
return jsonify({"status": "ok", "stats": stats})
@admin_bp.route("/users", methods=["GET"])
@admin_required
async def get_users():
users = await list_users()
return jsonify({"users": [u.to_dict() for u in users]})
@admin_bp.route("/users/<int:user_id>", methods=["DELETE"])
@admin_required
async def remove_user(user_id: int):
current_uid = get_current_user_id()
if user_id == current_uid:
return jsonify({"error": "Cannot delete your own account"}), 400
deleted = await delete_user(user_id)
if not deleted:
return jsonify({"error": "User not found"}), 404
return jsonify({"status": "ok"})
@admin_bp.route("/registration", methods=["GET"])
@admin_required
async def get_registration():
open_status = await is_registration_open()
return jsonify({"open": open_status})
@admin_bp.route("/registration", methods=["PUT"])
@admin_required
async def toggle_registration():
data = await request.get_json()
open_val = data.get("open")
if open_val is None:
return jsonify({"error": "Missing 'open' field"}), 400
uid = get_current_user_id()
await set_registration_open(uid, bool(open_val))
return jsonify({"status": "ok", "open": bool(open_val)})
+27 -1
View File
@@ -3,9 +3,11 @@ from quart import Blueprint, jsonify, request, session
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.services.auth import (
authenticate,
change_password,
create_user,
get_user_by_id,
get_user_count,
is_registration_open,
)
auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth")
@@ -13,6 +15,9 @@ auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth")
@auth_bp.route("/register", methods=["POST"])
async def register():
if not await is_registration_open():
return jsonify({"error": "Registration is closed"}), 403
data = await request.get_json()
username = (data.get("username") or "").strip()
password = data.get("password") or ""
@@ -64,7 +69,28 @@ async def me():
return jsonify(user.to_dict())
@auth_bp.route("/password", methods=["PUT"])
@login_required
async def update_password():
data = await request.get_json()
current = data.get("current_password") or ""
new_pw = data.get("new_password") or ""
if not current:
return jsonify({"error": "Current password is required"}), 400
if len(new_pw) < 8:
return jsonify({"error": "New password must be at least 8 characters"}), 400
uid = get_current_user_id()
success = await change_password(uid, current, new_pw)
if not success:
return jsonify({"error": "Current password is incorrect"}), 403
return jsonify({"status": "ok"})
@auth_bp.route("/status", methods=["GET"])
async def status():
count = await get_user_count()
return jsonify({"has_users": count > 0})
reg_open = await is_registration_open()
return jsonify({"has_users": count > 0, "registration_open": reg_open})