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
+3
View File
@@ -20,6 +20,9 @@ def build_assist_messages(
f"--- Full Document ---\n{truncated_body}\n--- End Document ---\n\n"
"The user will give you a specific section of the document and an instruction. "
"Output ONLY the replacement text for that section. "
"If the target section starts with a markdown heading (e.g. ## Heading), "
"your output MUST also start with a heading at the same level. "
"You may revise the heading text but do not remove it. "
"Do not include other sections, explanatory text, or markdown code fences around the output. "
"Match the document's existing tone and style."
)
+59
View File
@@ -63,6 +63,8 @@ async def create_user(
.where(Setting.user_id == user.id)
.values(user_id=user.id)
)
# Auto-close registration after first user setup
session.add(Setting(user_id=user.id, key="registration_open", value="false"))
await session.commit()
logger.info("First user '%s' created as admin, claimed orphaned data", username)
@@ -83,3 +85,60 @@ async def authenticate(username: str, password: str) -> User | None:
async def get_user_by_id(user_id: int) -> User | None:
async with async_session() as session:
return await session.get(User, user_id)
async def change_password(user_id: int, current_password: str, new_password: str) -> bool:
"""Change a user's password. Returns True on success, False if current password is wrong."""
async with async_session() as session:
user = await session.get(User, user_id)
if not user:
return False
if not verify_password(current_password, user.password_hash):
return False
user.password_hash = hash_password(new_password)
await session.commit()
logger.info("Password changed for user %d (%s)", user_id, user.username)
return True
async def is_registration_open() -> bool:
"""Check if new user registration is allowed.
Always open when no users exist (first-user setup).
Otherwise reads the admin's 'registration_open' setting (default closed).
"""
user_count = await get_user_count()
if user_count == 0:
return True
async with async_session() as session:
# Find the admin user's registration_open setting
result = await session.execute(
select(Setting)
.join(User, Setting.user_id == User.id)
.where(User.role == "admin", Setting.key == "registration_open")
)
setting = result.scalar_one_or_none()
return setting.value == "true" if setting else False
async def list_users() -> list[User]:
async with async_session() as session:
result = await session.execute(select(User).order_by(User.created_at))
return list(result.scalars().all())
async def delete_user(user_id: int) -> bool:
async with async_session() as session:
user = await session.get(User, user_id)
if not user:
return False
await session.delete(user)
await session.commit()
logger.info("Deleted user %d (%s)", user_id, user.username)
return True
async def set_registration_open(admin_user_id: int, open: bool) -> None:
from fabledassistant.services.settings import set_setting
await set_setting(admin_user_id, "registration_open", "true" if open else "false")