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
+20
View File
@@ -67,10 +67,30 @@ Configuration is done via environment variables. See `docker-compose.yml` for de
| `SECRET_KEY` | (required) | Session signing key |
| `OLLAMA_BASE_URL` | `http://ollama:11434` | Ollama API endpoint |
| `DEFAULT_MODEL` | `llama3.1` | Default LLM model to auto-pull on startup |
| `SECURE_COOKIES` | `false` | Set to `true` when behind TLS to add `Secure` flag to session cookies |
| `LOG_LEVEL` | `INFO` | Logging level |
For production deployments, `docker-compose.prod.yml` supports Docker secrets (`SECRET_KEY_FILE`, `DATABASE_URL_FILE`) and includes network isolation, health checks, and resource limits.
## Production Deployment
### Reverse Proxy (Required)
Fabled Assistant does **not** handle SSL/TLS. You must run it behind a reverse proxy for production use:
- **Nginx**, **Traefik**, or **Caddy** in front of the app container
- Terminate TLS at the proxy and forward traffic to port 5000
- **Do not expose port 5000 directly to the internet**
- **Rate-limit auth endpoints** at the proxy layer — recommended: limit `/api/auth/login` and `/api/auth/register` to ~5 requests/minute per IP to prevent brute-force attacks
- **Set Content Security Policy headers** at the proxy — recommended: `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'`
### Security Checklist
- **Set a strong `SECRET_KEY`** — used to sign session cookies. Generate one with `python -c "import secrets; print(secrets.token_hex(32))"` or use Docker secrets via `SECRET_KEY_FILE`.
- **Registration auto-closes** — After the first user registers (who becomes admin), registration is closed by default. The admin can re-enable it from the user management page (`/admin/users`).
- **Use Docker secrets in production** — `docker-compose.prod.yml` reads `SECRET_KEY_FILE` and `DATABASE_URL_FILE` instead of plain environment variables.
- **Keep Ollama on an internal network** — The default compose files keep Ollama on a Docker-internal network, not exposed to the host.
## Technical Overview
### Stack
-4
View File
@@ -10,7 +10,6 @@ services:
OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.1}"
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
networks:
- fabledassistant_frontend
- fabledassistant_backend
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
@@ -80,8 +79,5 @@ volumes:
ollama_models:
networks:
fabledassistant_frontend:
driver: overlay
fabledassistant_backend:
driver: overlay
internal: true
+1
View File
@@ -61,6 +61,7 @@ router.afterEach(() => {
<router-link to="/tasks" class="nav-link">Tasks</router-link>
<router-link to="/chat" class="nav-link">Chat</router-link>
<router-link to="/settings" class="nav-link">Settings</router-link>
<router-link v-if="authStore.isAdmin" to="/admin/users" class="nav-link">Users</router-link>
<span class="status-indicator" :class="statusClass" :title="statusLabel">
<span class="status-dot"></span>
</span>
+5
View File
@@ -77,6 +77,11 @@ const router = createRouter({
name: "settings",
component: () => import("@/views/SettingsView.vue"),
},
{
path: "/admin/users",
name: "admin-users",
component: () => import("@/views/UserManagementView.vue"),
},
],
});
+4
View File
@@ -7,6 +7,7 @@ export const useAuthStore = defineStore("auth", () => {
const user = ref<User | null>(null);
const loading = ref(true);
const hasUsers = ref(true);
const registrationOpen = ref(false);
const isAuthenticated = computed(() => user.value !== null);
const isAdmin = computed(() => user.value?.role === "admin");
@@ -26,8 +27,10 @@ export const useAuthStore = defineStore("auth", () => {
try {
const data = await apiGet<AuthStatus>("/api/auth/status");
hasUsers.value = data.has_users;
registrationOpen.value = data.registration_open;
} catch {
hasUsers.value = true;
registrationOpen.value = false;
}
}
@@ -52,6 +55,7 @@ export const useAuthStore = defineStore("auth", () => {
user,
loading,
hasUsers,
registrationOpen,
isAuthenticated,
isAdmin,
checkAuth,
+1
View File
@@ -8,4 +8,5 @@ export interface User {
export interface AuthStatus {
has_users: boolean;
registration_open: boolean;
}
+117 -45
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from "vue";
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth";
import AppLogo from "@/components/AppLogo.vue";
@@ -9,11 +9,30 @@ const authStore = useAuthStore();
const username = ref("");
const password = ref("");
const confirmPassword = ref("");
const email = ref("");
const error = ref("");
const submitting = ref(false);
const checking = ref(true);
const passwordMismatch = computed(
() => confirmPassword.value.length > 0 && password.value !== confirmPassword.value
);
const canSubmit = computed(
() => !submitting.value && !passwordMismatch.value && password.value.length >= 8
);
onMounted(async () => {
await authStore.checkHasUsers();
checking.value = false;
});
async function handleSubmit() {
if (passwordMismatch.value) {
error.value = "Passwords do not match";
return;
}
error.value = "";
submitting.value = true;
try {
@@ -36,50 +55,77 @@ async function handleSubmit() {
<main class="auth-page">
<div class="auth-card">
<div class="auth-brand"><AppLogo :size="32" /><h1>Create Account</h1></div>
<form @submit.prevent="handleSubmit">
<div class="field">
<label for="username">Username</label>
<input
id="username"
v-model="username"
type="text"
autocomplete="username"
required
class="input"
/>
</div>
<div class="field">
<label for="email">Email (optional)</label>
<input
id="email"
v-model="email"
type="email"
autocomplete="email"
class="input"
/>
</div>
<div class="field">
<label for="password">Password</label>
<input
id="password"
v-model="password"
type="password"
autocomplete="new-password"
required
minlength="8"
class="input"
/>
<p class="field-hint">Must be at least 8 characters</p>
</div>
<p v-if="error" class="error-msg">{{ error }}</p>
<button type="submit" class="btn-submit" :disabled="submitting">
{{ submitting ? "Creating account..." : "Create Account" }}
</button>
</form>
<p class="auth-footer">
Already have an account?
<router-link to="/login">Sign in</router-link>
</p>
<div v-if="checking" class="loading-msg">Checking registration status...</div>
<div v-else-if="!authStore.registrationOpen" class="closed-msg">
<p>Registration is currently closed.</p>
<p>Contact an administrator to get an account.</p>
<p class="auth-footer">
Already have an account?
<router-link to="/login">Sign in</router-link>
</p>
</div>
<template v-else>
<form @submit.prevent="handleSubmit">
<div class="field">
<label for="username">Username</label>
<input
id="username"
v-model="username"
type="text"
autocomplete="username"
required
class="input"
/>
</div>
<div class="field">
<label for="email">Email (optional)</label>
<input
id="email"
v-model="email"
type="email"
autocomplete="email"
class="input"
/>
</div>
<div class="field">
<label for="password">Password</label>
<input
id="password"
v-model="password"
type="password"
autocomplete="new-password"
required
minlength="8"
class="input"
/>
<p class="field-hint">Must be at least 8 characters</p>
</div>
<div class="field">
<label for="confirm-password">Confirm Password</label>
<input
id="confirm-password"
v-model="confirmPassword"
type="password"
autocomplete="new-password"
required
class="input"
:class="{ 'input-error': passwordMismatch }"
/>
<p v-if="passwordMismatch" class="error-hint">Passwords do not match</p>
</div>
<p v-if="error" class="error-msg">{{ error }}</p>
<button type="submit" class="btn-submit" :disabled="!canSubmit">
{{ submitting ? "Creating account..." : "Create Account" }}
</button>
</form>
<p class="auth-footer">
Already have an account?
<router-link to="/login">Sign in</router-link>
</p>
</template>
</div>
</main>
</template>
@@ -111,6 +157,21 @@ async function handleSubmit() {
margin: 0;
text-align: center;
}
.loading-msg {
text-align: center;
color: var(--color-text-muted);
font-size: 0.9rem;
padding: 1rem 0;
}
.closed-msg {
text-align: center;
color: var(--color-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.closed-msg p {
margin: 0.5rem 0;
}
.field {
margin-bottom: 1rem;
}
@@ -134,11 +195,22 @@ async function handleSubmit() {
outline: none;
border-color: var(--color-primary);
}
.input-error {
border-color: var(--color-danger);
}
.input-error:focus {
border-color: var(--color-danger);
}
.field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--color-text-muted);
}
.error-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--color-danger);
}
.error-msg {
color: var(--color-danger);
font-size: 0.9rem;
+91
View File
@@ -3,12 +3,17 @@ import { ref, computed, onMounted } from "vue";
import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { apiPut } from "@/api/client";
import type { ModelInfo } from "@/types/settings";
const store = useSettingsStore();
const authStore = useAuthStore();
const toastStore = useToastStore();
const assistantName = ref("");
const currentPassword = ref("");
const newPassword = ref("");
const confirmNewPassword = ref("");
const changingPassword = ref(false);
const saving = ref(false);
const saved = ref(false);
const pullProgress = ref<{ model: string; percent: number } | null>(null);
@@ -190,6 +195,33 @@ onMounted(async () => {
store.fetchInstalledModels();
});
async function changePassword() {
if (newPassword.value !== confirmNewPassword.value) {
toastStore.show("New passwords do not match", "error");
return;
}
changingPassword.value = true;
try {
await apiPut("/api/auth/password", {
current_password: currentPassword.value,
new_password: newPassword.value,
});
toastStore.show("Password changed successfully");
currentPassword.value = "";
newPassword.value = "";
confirmNewPassword.value = "";
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to change password", "error");
} else {
toastStore.show("Failed to change password", "error");
}
} finally {
changingPassword.value = false;
}
}
async function saveAssistant() {
saving.value = true;
saved.value = false;
@@ -345,6 +377,54 @@ async function handleRestoreFile(event: Event) {
</div>
</section>
<section class="settings-section">
<h2>Change Password</h2>
<div class="field">
<label for="current-password">Current Password</label>
<input
id="current-password"
v-model="currentPassword"
type="password"
autocomplete="current-password"
class="input"
/>
</div>
<div class="field">
<label for="new-password">New Password</label>
<input
id="new-password"
v-model="newPassword"
type="password"
autocomplete="new-password"
class="input"
/>
<p class="field-hint">Must be at least 8 characters</p>
</div>
<div class="field">
<label for="confirm-new-password">Confirm New Password</label>
<input
id="confirm-new-password"
v-model="confirmNewPassword"
type="password"
autocomplete="new-password"
class="input"
:class="{ 'input-error': confirmNewPassword && newPassword !== confirmNewPassword }"
/>
<p v-if="confirmNewPassword && newPassword !== confirmNewPassword" class="error-hint">
Passwords do not match
</p>
</div>
<div class="actions">
<button
class="btn-save"
@click="changePassword"
:disabled="changingPassword || !currentPassword || newPassword.length < 8 || newPassword !== confirmNewPassword"
>
{{ changingPassword ? "Changing..." : "Change Password" }}
</button>
</div>
</section>
<section class="settings-section">
<h2>Model</h2>
<p class="section-desc">
@@ -538,6 +618,17 @@ async function handleRestoreFile(event: Event) {
font-size: 0.9rem;
font-weight: 600;
}
.input-error {
border-color: var(--color-danger);
}
.input-error:focus {
border-color: var(--color-danger);
}
.error-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--color-danger);
}
/* Model list */
.model-list {
+386
View File
@@ -0,0 +1,386 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { apiGet, apiPut, apiDelete } from "@/api/client";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import type { User } from "@/types/auth";
const authStore = useAuthStore();
const toastStore = useToastStore();
const users = ref<User[]>([]);
const registrationOpen = ref(false);
const loading = ref(true);
const toggling = ref(false);
const confirmDeleteId = ref<number | null>(null);
const deleting = ref<number | null>(null);
onMounted(async () => {
await Promise.all([fetchUsers(), fetchRegistration()]);
loading.value = false;
});
async function fetchUsers() {
try {
const data = await apiGet<{ users: User[] }>("/api/admin/users");
users.value = data.users;
} catch {
toastStore.show("Failed to load users", "error");
}
}
async function fetchRegistration() {
try {
const data = await apiGet<{ open: boolean }>("/api/admin/registration");
registrationOpen.value = data.open;
} catch {
// Ignore — will default to false
}
}
async function toggleRegistration() {
toggling.value = true;
try {
const data = await apiPut<{ open: boolean }>("/api/admin/registration", {
open: !registrationOpen.value,
});
registrationOpen.value = data.open;
toastStore.show(data.open ? "Registration opened" : "Registration closed");
} catch {
toastStore.show("Failed to update registration setting", "error");
} finally {
toggling.value = false;
}
}
function confirmDelete(userId: number) {
if (confirmDeleteId.value === userId) {
deleteUser(userId);
} else {
confirmDeleteId.value = userId;
}
}
function cancelDelete() {
confirmDeleteId.value = null;
}
async function deleteUser(userId: number) {
confirmDeleteId.value = null;
deleting.value = userId;
try {
await apiDelete(`/api/admin/users/${userId}`);
users.value = users.value.filter((u) => u.id !== userId);
toastStore.show("User deleted");
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to delete user", "error");
} else {
toastStore.show("Failed to delete user", "error");
}
} finally {
deleting.value = null;
}
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
</script>
<template>
<main class="users-page">
<h1>User Management</h1>
<section class="settings-section">
<h2>Registration</h2>
<div class="registration-row">
<div class="registration-info">
<p class="registration-status">
Registration is currently
<strong :class="registrationOpen ? 'text-success' : 'text-muted'">
{{ registrationOpen ? "open" : "closed" }}
</strong>
</p>
<p class="field-hint">
When closed, new users can only be added by an administrator.
</p>
</div>
<button
class="btn-toggle"
:class="registrationOpen ? 'btn-toggle-close' : 'btn-toggle-open'"
@click="toggleRegistration"
:disabled="toggling"
>
{{ toggling ? "Updating..." : registrationOpen ? "Close Registration" : "Open Registration" }}
</button>
</div>
</section>
<section class="settings-section">
<h2>Users</h2>
<div v-if="loading" class="loading-msg">Loading users...</div>
<div v-else-if="users.length === 0" class="empty-msg">No users found.</div>
<table v-else class="users-table">
<thead>
<tr>
<th>Username</th>
<th class="hide-mobile">Email</th>
<th>Role</th>
<th class="hide-mobile">Joined</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id">
<td class="cell-username">{{ u.username }}</td>
<td class="hide-mobile cell-email">{{ u.email || "—" }}</td>
<td>
<span class="role-badge" :class="u.role === 'admin' ? 'role-admin' : 'role-user'">
{{ u.role }}
</span>
</td>
<td class="hide-mobile cell-date">{{ formatDate(u.created_at) }}</td>
<td class="cell-actions">
<template v-if="u.id === authStore.user?.id">
<span class="you-label">You</span>
</template>
<template v-else-if="confirmDeleteId === u.id">
<button
class="btn-confirm-delete"
@click="confirmDelete(u.id)"
:disabled="deleting !== null"
>
{{ deleting === u.id ? "Deleting..." : "Confirm" }}
</button>
<button class="btn-cancel-delete" @click="cancelDelete">Cancel</button>
</template>
<template v-else>
<button
class="btn-delete"
@click="confirmDelete(u.id)"
:disabled="deleting !== null"
>
Delete
</button>
</template>
</td>
</tr>
</tbody>
</table>
</section>
</main>
</template>
<style scoped>
.users-page {
max-width: 960px;
margin: 2rem auto;
padding: 0 1rem;
}
.users-page h1 {
margin: 0 0 1.5rem;
}
.settings-section {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1.25rem;
margin-bottom: 1.5rem;
}
.settings-section h2 {
margin: 0 0 0.75rem;
font-size: 1.1rem;
}
/* Registration toggle */
.registration-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.registration-info {
flex: 1;
}
.registration-status {
margin: 0;
font-size: 0.95rem;
}
.text-success {
color: var(--color-success);
}
.text-muted {
color: var(--color-text-muted);
}
.field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--color-text-muted);
}
.btn-toggle {
padding: 0.45rem 1rem;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
white-space: nowrap;
}
.btn-toggle:disabled {
opacity: 0.6;
cursor: default;
}
.btn-toggle-open {
background: var(--color-primary);
color: #fff;
}
.btn-toggle-open:hover:not(:disabled) {
opacity: 0.9;
}
.btn-toggle-close {
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.btn-toggle-close:hover:not(:disabled) {
border-color: var(--color-warning);
color: var(--color-warning);
}
/* Users table */
.loading-msg,
.empty-msg {
text-align: center;
color: var(--color-text-muted);
font-size: 0.9rem;
padding: 1rem 0;
}
.users-table {
width: 100%;
border-collapse: collapse;
}
.users-table th {
text-align: left;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);
}
.users-table td {
padding: 0.65rem 0.75rem;
border-bottom: 1px solid var(--color-border);
font-size: 0.9rem;
}
.users-table tbody tr:last-child td {
border-bottom: none;
}
.cell-username {
font-weight: 600;
}
.cell-email {
color: var(--color-text-secondary);
}
.cell-date {
color: var(--color-text-muted);
font-size: 0.85rem;
}
.cell-actions {
white-space: nowrap;
}
/* Role badges */
.role-badge {
display: inline-block;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.15rem 0.4rem;
border-radius: var(--radius-sm);
}
.role-admin {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
}
.role-user {
color: var(--color-text-muted);
background: var(--color-bg-secondary);
}
/* Action buttons */
.you-label {
font-size: 0.8rem;
color: var(--color-text-muted);
font-style: italic;
}
.btn-delete {
padding: 0.25rem 0.6rem;
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
}
.btn-delete:hover:not(:disabled) {
border-color: var(--color-danger);
color: var(--color-danger);
}
.btn-delete:disabled {
opacity: 0.4;
cursor: default;
}
.btn-confirm-delete {
padding: 0.25rem 0.6rem;
background: var(--color-danger);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
margin-right: 0.25rem;
}
.btn-confirm-delete:hover:not(:disabled) {
filter: brightness(0.9);
}
.btn-confirm-delete:disabled {
opacity: 0.6;
cursor: default;
}
.btn-cancel-delete {
padding: 0.25rem 0.6rem;
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
}
.btn-cancel-delete:hover {
color: var(--color-text);
border-color: var(--color-text-muted);
}
@media (max-width: 768px) {
.registration-row {
flex-direction: column;
align-items: flex-start;
}
.btn-toggle {
width: 100%;
}
}
</style>
+9
View File
@@ -26,6 +26,15 @@ def create_app() -> Quart:
app = Quart(__name__, static_folder=None)
app.secret_key = Config.SECRET_KEY
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
app.config["SESSION_COOKIE_SECURE"] = Config.SECURE_COOKIES
if Config.SECRET_KEY == "dev-secret-change-me":
logger.warning(
"SECRET_KEY is set to the default value — session cookies are insecure. "
"Set SECRET_KEY or SECRET_KEY_FILE for production use."
)
app.register_blueprint(admin_bp)
app.register_blueprint(api)
+1
View File
@@ -25,4 +25,5 @@ class Config:
OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434")
OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "llama3.1")
SECRET_KEY: str = _read_secret("SECRET_KEY", "SECRET_KEY_FILE", "dev-secret-change-me")
SECURE_COOKIES: bool = os.environ.get("SECURE_COOKIES", "").lower() in ("1", "true", "yes")
LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO")
+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})
+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")
+39 -10
View File
@@ -12,7 +12,7 @@
> Include file-level details in the commit body when the change is non-trivial.
## Last Updated
2026-02-11 — Phase 5.2: Tiptap Inline-Preview Editor + Layout Improvements
2026-02-12 — Phase 5.3: Registration Control, User Management, Security Hardening
## Project Overview
Fabled Assistant is a self-hosted note-taking and task-tracking application with
@@ -76,6 +76,12 @@ for AI-assisted features.
`build_context()` returns `(messages, context_meta)` tuple; metadata includes
auto-found note IDs/titles sent to frontend via SSE `context` event before streaming.
Multi-word search splits terms into per-word ILIKE with AND logic (not adjacent match).
- **Reverse proxy required for production:** The app does not terminate TLS. A
reverse proxy (Nginx/Traefik/Caddy) must sit in front of port 5000. Do not
expose the app directly to the internet.
- **Registration auto-closes:** After the first user registers (admin), registration
is closed by default. The admin can toggle it from `/admin/users`. The setting is
stored as the admin user's `registration_open` setting key.
- **No blocking long-running operations:** Any potentially slow operation (model
pulls, LLM calls, URL fetching, etc.) must never block app startup or freeze the
UI. Backend uses SSE streaming for incremental responses (chat messages and model
@@ -91,6 +97,10 @@ for AI-assisted features.
`CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and `DO $$ BEGIN
CREATE TYPE ... EXCEPTION WHEN duplicate_object` to allow safe re-runs and
fresh database creation.
- **Session cookie security:** `HttpOnly` and `SameSite=Lax` always set. `Secure`
flag controlled by `SECURE_COOKIES` env var (enable when behind TLS).
- **Default SECRET_KEY warning:** App logs a WARNING on startup if the default
`dev-secret-change-me` key is in use.
### High-Level Component Diagram
```
@@ -199,7 +209,7 @@ fabledassistant/
│ ├── __init__.py
│ ├── app.py # Quart app factory: SPA via 404 handler, JSON 404/500 for API, request logging middleware
│ ├── auth.py # Auth decorators: login_required, admin_required, get_current_user_id
│ ├── config.py # Config from env vars + Docker secrets file support (_read_secret)
│ ├── config.py # Config from env vars + Docker secrets file support (_read_secret) + SECURE_COOKIES flag
│ ├── models/
│ │ ├── __init__.py # async_session factory, Base, imports all models
│ │ ├── user.py # User model (id, username, email, password_hash, role, created_at)
@@ -210,13 +220,13 @@ fabledassistant/
│ │ ├── __init__.py
│ │ ├── api.py # /api blueprint with /health endpoint (public)
│ │ ├── auth.py # /api/auth blueprint: register, login, logout, me, status
│ │ ├── admin.py # /api/admin blueprint: backup (user/full), restore (admin only)
│ │ ├── admin.py # /api/admin blueprint: backup, restore, user management, registration toggle (admin only)
│ │ ├── chat.py # /api/chat blueprint: conversations CRUD, SSE message streaming (all @login_required)
│ │ ├── notes.py # /api/notes CRUD + wikilinks + backlinks (all @login_required)
│ │ ├── tasks.py # /api/tasks CRUD + PATCH status (all @login_required)
│ │ └── settings.py # /api/settings GET/PUT — per-user settings (@login_required)
│ ├── services/
│ │ ├── auth.py # Auth business logic: hash_password, verify_password, create_user, authenticate, get_user_by_id
│ │ ├── auth.py # Auth business logic: hash_password, verify_password, create_user, authenticate, get_user_by_id, is_registration_open, list_users, delete_user, set_registration_open
│ │ ├── backup.py # Backup/restore: export_full_backup, export_user_backup, restore_full_backup
│ │ ├── notes.py # CRUD with user_id isolation, is_task filter, convert, backlinks, search_notes_for_context
│ │ ├── llm.py # Ollama interaction: build_context with user_id, streaming, URL fetching
@@ -268,7 +278,8 @@ fabledassistant/
│ │ └── markdownSerializer.ts # Tiptap JSON → markdown serializer: handles all StarterKit nodes + marks
│ ├── views/
│ │ ├── LoginView.vue # Login form with error display, link to register
│ │ ├── RegisterView.vue # Register form (username, password, email), first-user setup
│ │ ├── RegisterView.vue # Register form with password confirmation; shows "closed" message when registration disabled
│ │ ├── UserManagementView.vue # Admin user management: registration toggle, user list with delete
│ │ ├── ChatView.vue # Dedicated /chat page: responsive sidebar (overlay on mobile), bubble messages, note picker, context pills
│ │ ├── HomeView.vue # Landing page: recent notes + tasks + recent chats
│ │ ├── SettingsView.vue # Settings page: assistant name, model catalog, data export/restore (admin)
@@ -294,7 +305,7 @@ fabledassistant/
│ │ ├── PaginationBar.vue # Prev/next + page numbers
│ │ └── ToastNotification.vue # Fixed-position toast container with close button, warning support
│ └── router/
│ └── index.ts # Routes: /, /login, /register, /notes/*, /tasks/*, /chat, /chat/:id, /settings; beforeEach auth guard
│ └── index.ts # Routes: /, /login, /register, /notes/*, /tasks/*, /chat, /chat/:id, /settings, /admin/users; beforeEach auth guard
└── public/
```
@@ -303,13 +314,18 @@ fabledassistant/
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/health` | Health check (public) |
| GET | `/api/auth/status` | Check if any users exist (public) |
| POST | `/api/auth/register` | Register new user (first user becomes admin) |
| GET | `/api/auth/status` | Check if any users exist + registration open status (public) |
| POST | `/api/auth/register` | Register new user (first user becomes admin; returns 403 if registration closed) |
| POST | `/api/auth/login` | Login with username/password |
| POST | `/api/auth/logout` | Logout (clear session) |
| GET | `/api/auth/me` | Get current user info |
| PUT | `/api/auth/password` | Change password (body: `{current_password, new_password}`) |
| GET | `/api/admin/backup` | Export backup (`?scope=user` for own data, full requires admin) |
| POST | `/api/admin/restore` | Restore from JSON backup (admin only) |
| GET | `/api/admin/users` | List all users (admin only) |
| DELETE | `/api/admin/users/:id` | Delete a user (admin only, cannot delete self) |
| GET | `/api/admin/registration` | Get registration open/closed status (admin only) |
| PUT | `/api/admin/registration` | Toggle registration open/closed (admin only, body: `{open: bool}`) |
| GET | `/api/notes` | List notes (params: `q`, `tag`, `sort`, `order`, `limit`, `offset`; defaults to `is_task=false` — plain notes only; `?is_task=true` for tasks, `?all=true` for everything) |
| POST | `/api/notes` | Create note (body: `{title, body, status?, priority?, due_date?}` — tags auto-extracted) |
| GET | `/api/notes/tags` | List all tags from notes table (param: `q` for filter) |
@@ -583,6 +599,16 @@ When adding a new migration, follow these conventions:
- [x] **Accept trailing newline:** Accepted AI suggestions always end with a newline for clean separation
- [x] **Wider content area:** Max-width increased from 720px to 960px across all views
### Phase 5.3 — Registration Control, User Management, Security Hardening ✓
- [x] **Registration auto-closes:** After the first user registers (admin), registration is closed by default
- [x] **Registration toggle:** Admin can open/close registration from `/admin/users`
- [x] **Password confirmation:** Register form requires password confirmation with inline validation
- [x] **Admin user management:** `/admin/users` view with user list and delete (admin only)
- [x] **Session cookie hardening:** `HttpOnly`, `SameSite=Lax` always on; `Secure` via `SECURE_COOKIES` env var
- [x] **Default SECRET_KEY warning:** Logs WARNING on startup if default key is in use
- [x] **Password change:** `PUT /api/auth/password` endpoint + Settings UI section
- [x] **Production deployment docs:** README documents reverse proxy, rate limiting, CSP headers, SECRET_KEY, registration behavior
### Future / Stretch
- Tagging/labeling system with LLM-suggested tags
- Calendar/timeline view for tasks
@@ -598,7 +624,7 @@ When adding a new migration, follow these conventions:
- To reset database: `docker compose down -v && docker compose up --build`
## Current Status
**Phase:** Phase 5.2 complete. Tiptap Inline-Preview Editor + Layout Improvements.
**Phase:** Phase 5.3 complete. Registration Control, User Management, Security Hardening.
- Full note-taking and task-tracking CRUD with markdown, wikilinks, backlinks, tags
- **Tiptap WYSIWYG editor** with inline formatting preview, markdown round-trip, paste handling
- **Tag/wikilink autocomplete** via `@tiptap/suggestion` with heading disambiguation
@@ -611,7 +637,10 @@ When adding a new migration, follow these conventions:
- Context pills with promote/exclude controls; note picker in chat input
- Save assistant messages as notes (LLM-titled, chat-tagged); summarize conversations as notes
- Dedicated `/chat` page with responsive sidebar (overlay on mobile)
- Settings page: assistant name, model catalog, data export/restore (admin)
- Settings page: assistant name, model catalog, password change, data export/restore (admin)
- **Registration control**: auto-closes after first user, admin toggle, password confirmation
- **Admin user management**: `/admin/users` with user list + delete
- **Session cookie hardening**: HttpOnly, SameSite=Lax, optional Secure flag
- **App-wide layout fix**: navbar always visible, all views fit within viewport
- **Responsive design**: hamburger menu, mobile touch targets, responsive breakpoints
- **Docker Swarm production stack** with secrets, network isolation, health checks, resource limits