mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-04-19 21:39:17 -04:00
Closes #552 ## Summary Adds OIDC authentication and multi-user support to Shelfmark. Users can now be managed individually with per-user download settings, while maintaining full backwards compatibility with existing auth modes (no-auth, builtin, proxy, CWA). ### Authentication - **OIDC login** with PKCE, auto-discovery, group-based admin mapping - **Password fallback** when OIDC is enabled (prevents admin lockout) - **Auto-provisioning** of OIDC users (configurable on/off) - **Email-based linking** of pre-created users to OIDC accounts - **Lockout prevention** — requires a local admin before OIDC can be enabled ### User Management - **SQLite user database** (`users.db`) with admin CRUD API - **Users management tab** in settings UI (admin-only) - **Settings restricted to admins** in multi-user modes (builtin/OIDC) — non-admin users cannot access settings - Create, edit, and delete users with role assignment (admin/user) - Password management for builtin auth users - OIDC users shown with provider badge (password fields hidden) - Per-user configurable settings: - **Download destination** — custom folder path per user - **BookLore library & path** — dropdown select, each user's books go to their own library - **Email recipients** — per-user email delivery targets - **`{User}` template variable** — use in destination paths (e.g., `/books/{User}/`) - Settings override model: per-user values override globals, empty/unset falls back to global defaults ### Download Scoping - **Per-user download visibility** — non-admins only see their own downloads - **Username display** in downloads sidebar (shows who requested each download) - **WebSocket room-based filtering** — admins see all, users see only their own - **Download progress scoping** — progress events routed to correct user rooms ### BookLore Integration - **Dynamic dropdown selects** for library/path (replaces text inputs) - **Per-user library/path overrides** via user settings - **Options cache refresh** after Test Connection ### Security - SQL injection prevention (column whitelist on user updates) - Generic OIDC error messages (no internal detail leakage) - Admin self-deletion and last-local-admin deletion guards - OIDC role overwrite fix (only updates role when admin_group is configured) ## Migration **No migration script needed.** The `users.db` is created automatically on first startup. Existing builtin auth users are auto-migrated to the database on their first login. All other auth modes (no-auth, proxy, CWA) continue working unchanged. ## Test Plan - [x] All 519 tests passing, 0 failures - [ ] Test no-auth mode: settings accessible, downloads work without login - [ ] Test builtin auth: legacy credentials auto-migrate on login, new users can be created - [ ] Test OIDC auth: login flow, callback, auto-provisioning, group-based admin - [ ] Test CWA auth: unchanged behavior - [ ] Test proxy auth: unchanged behavior - [ ] Test per-user downloads: non-admin sees only own downloads - [ ] Test BookLore dropdowns: library/path selection, per-user overrides - [ ] Test Docker build: no Dockerfile changes needed --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
141 lines
5.2 KiB
Python
141 lines
5.2 KiB
Python
"""Tests for multi-user builtin authentication via users table."""
|
|
|
|
import os
|
|
import tempfile
|
|
|
|
import pytest
|
|
from werkzeug.security import generate_password_hash
|
|
|
|
from shelfmark.core.user_db import UserDB
|
|
|
|
|
|
@pytest.fixture
|
|
def db():
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
db_path = os.path.join(tmpdir, "users.db")
|
|
user_db = UserDB(db_path)
|
|
user_db.initialize()
|
|
yield user_db
|
|
|
|
|
|
class TestBuiltinMultiUserLogin:
|
|
"""Builtin auth should support multiple users via users table."""
|
|
|
|
def test_create_builtin_user_with_password(self, db):
|
|
password_hash = generate_password_hash("secret123")
|
|
user = db.create_user(
|
|
username="alice",
|
|
password_hash=password_hash,
|
|
role="user",
|
|
)
|
|
assert user["username"] == "alice"
|
|
assert user["role"] == "user"
|
|
|
|
def test_create_admin_and_regular_user(self, db):
|
|
db.create_user(username="admin", password_hash=generate_password_hash("admin123"), role="admin")
|
|
db.create_user(username="user1", password_hash=generate_password_hash("user123"), role="user")
|
|
users = db.list_users()
|
|
assert len(users) == 2
|
|
roles = {u["username"]: u["role"] for u in users}
|
|
assert roles["admin"] == "admin"
|
|
assert roles["user1"] == "user"
|
|
|
|
def test_authenticate_builtin_user(self, db):
|
|
from werkzeug.security import check_password_hash
|
|
|
|
password = "mypassword"
|
|
password_hash = generate_password_hash(password)
|
|
db.create_user(username="bob", password_hash=password_hash, role="user")
|
|
user = db.get_user(username="bob")
|
|
assert user is not None
|
|
assert check_password_hash(user["password_hash"], password)
|
|
|
|
def test_authenticate_wrong_password(self, db):
|
|
from werkzeug.security import check_password_hash
|
|
|
|
password_hash = generate_password_hash("correct")
|
|
db.create_user(username="carol", password_hash=password_hash, role="user")
|
|
user = db.get_user(username="carol")
|
|
assert not check_password_hash(user["password_hash"], "wrong")
|
|
|
|
def test_user_not_found(self, db):
|
|
user = db.get_user(username="nonexistent")
|
|
assert user is None
|
|
|
|
|
|
class TestMigrateBuiltinConfig:
|
|
"""When migrating from single-user config to multi-user DB,
|
|
the existing admin credentials should be auto-imported."""
|
|
|
|
def test_migrate_existing_admin(self, db):
|
|
"""Simulate migrating BUILTIN_USERNAME/BUILTIN_PASSWORD_HASH to users table."""
|
|
existing_username = "myadmin"
|
|
existing_hash = generate_password_hash("oldpassword")
|
|
|
|
# No users yet
|
|
assert len(db.list_users()) == 0
|
|
|
|
# Migration: create admin user from config values
|
|
user = db.create_user(
|
|
username=existing_username,
|
|
password_hash=existing_hash,
|
|
role="admin",
|
|
)
|
|
assert user["username"] == "myadmin"
|
|
assert user["role"] == "admin"
|
|
assert user["password_hash"] == existing_hash
|
|
|
|
def test_skip_migration_if_users_exist(self, db):
|
|
"""Don't re-migrate if users already exist in DB."""
|
|
db.create_user(username="existing_admin", password_hash=generate_password_hash("pw"), role="admin")
|
|
# Should have 1 user already, migration should be skipped
|
|
assert len(db.list_users()) == 1
|
|
|
|
|
|
class TestBuiltinLoginLogic:
|
|
"""Test the login logic that mirrors what main.py will do for builtin multi-user."""
|
|
|
|
def _builtin_login(self, db, username, password):
|
|
"""Mirror the multi-user builtin login logic."""
|
|
from werkzeug.security import check_password_hash
|
|
|
|
user = db.get_user(username=username)
|
|
if not user:
|
|
return None
|
|
if not user.get("password_hash"):
|
|
return None
|
|
if not check_password_hash(user["password_hash"], password):
|
|
return None
|
|
return {
|
|
"user_id": username,
|
|
"db_user_id": user["id"],
|
|
"is_admin": user["role"] == "admin",
|
|
}
|
|
|
|
def test_login_admin(self, db):
|
|
db.create_user(username="admin", password_hash=generate_password_hash("admin123"), role="admin")
|
|
result = self._builtin_login(db, "admin", "admin123")
|
|
assert result is not None
|
|
assert result["is_admin"] is True
|
|
assert result["user_id"] == "admin"
|
|
|
|
def test_login_regular_user(self, db):
|
|
db.create_user(username="user1", password_hash=generate_password_hash("pass1"), role="user")
|
|
result = self._builtin_login(db, "user1", "pass1")
|
|
assert result is not None
|
|
assert result["is_admin"] is False
|
|
|
|
def test_login_wrong_password(self, db):
|
|
db.create_user(username="user1", password_hash=generate_password_hash("correct"), role="user")
|
|
result = self._builtin_login(db, "user1", "wrong")
|
|
assert result is None
|
|
|
|
def test_login_nonexistent_user(self, db):
|
|
result = self._builtin_login(db, "nobody", "pass")
|
|
assert result is None
|
|
|
|
def test_login_sets_db_user_id(self, db):
|
|
user = db.create_user(username="dave", password_hash=generate_password_hash("pw"), role="user")
|
|
result = self._builtin_login(db, "dave", "pw")
|
|
assert result["db_user_id"] == user["id"]
|