From 5bed0b20f44ac74ffc3cc5d6490cfff1048d9937 Mon Sep 17 00:00:00 2001 From: Alex <25013571+alexhb1@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:38:28 +0000 Subject: [PATCH] Patch: Multi-user and OIDC polish (#612) - Moved backend OIDC functionality to external library Authlib to help maintainability - Separated User settings UI into individual components, allowing for standard settings UI decorator components to be used. - Added full support for reverse proxy and CWA users alongside local and OIDC - Added mapping and syncing functionality for OIDC, CWA and reverse proxy users - Added per-user settings into the app-wide config system. Each config can be declared as user-overrideable, and app-wide functionality can now receive user-specific options via standard config calls. - Added per-user audiobook destination config - Updated login modal UI for simplified login, plus custom labels for OIDC login - Added user visibility in header dropdown - Unified "restrict settings to admin" to use app-wide user roles. --- requirements-base.txt | 1 + shelfmark/config/migrations.py | 123 +++ shelfmark/config/security.py | 502 ++++------ shelfmark/config/security_handlers.py | 87 ++ shelfmark/config/settings.py | 86 +- shelfmark/config/users_settings.py | 16 +- shelfmark/core/admin_routes.py | 313 ++++-- shelfmark/core/admin_settings_routes.py | 196 ++++ shelfmark/core/auth_modes.py | 104 ++ shelfmark/core/config.py | 84 +- shelfmark/core/cwa_user_sync.py | 75 ++ shelfmark/core/external_user_linking.py | 283 ++++++ shelfmark/core/oidc_auth.py | 64 +- shelfmark/core/oidc_routes.py | 294 ++---- shelfmark/core/settings_registry.py | 38 + shelfmark/core/user_db.py | 101 +- shelfmark/core/utils.py | 79 +- shelfmark/download/orchestrator.py | 108 +-- shelfmark/download/outputs/booklore.py | 25 +- shelfmark/download/postprocess/destination.py | 9 +- shelfmark/main.py | 209 ++-- src/frontend/src/App.tsx | 84 +- .../src/components/EmailRecipientModal.tsx | 187 ---- src/frontend/src/components/Header.tsx | 55 +- src/frontend/src/components/LoginForm.tsx | 346 ++++--- .../components/settings/SettingsContent.tsx | 135 +-- .../src/components/settings/SettingsModal.tsx | 129 ++- .../src/components/settings/UsersPanel.tsx | 902 +++++------------- .../settings/shared/FieldWrapper.tsx | 88 +- .../settings/shared/SettingsSubpage.tsx | 15 + .../src/components/settings/shared/index.ts | 1 + .../settings/users/UserAuthSourceBadge.tsx | 29 + .../components/settings/users/UserCard.tsx | 276 ++++++ .../settings/users/UserListView.tsx | 284 ++++++ .../settings/users/UserOverridesSection.tsx | 384 ++++++++ .../settings/users/UserOverridesView.tsx | 52 + .../src/components/settings/users/index.ts | 10 + .../src/components/settings/users/types.ts | 72 ++ .../components/settings/users/useUserForm.ts | 69 ++ .../settings/users/useUserMutations.ts | 151 +++ .../settings/users/useUsersFetch.ts | 84 ++ .../settings/users/useUsersPanelState.ts | 30 + .../src/components/shared/Tooltip.tsx | 2 + src/frontend/src/hooks/useAuth.ts | 40 +- src/frontend/src/pages/LoginPage.tsx | 14 +- src/frontend/src/services/api.ts | 79 +- src/frontend/src/types/index.ts | 9 +- src/frontend/src/types/settings.ts | 1 + tests/config/test_security.py | 370 ++++--- tests/core/test_admin_users_api.py | 552 ++++++++++- tests/core/test_booklore_multiuser.py | 95 +- tests/core/test_config_user_overrides.py | 62 ++ tests/core/test_cwa_user_sync.py | 95 ++ tests/core/test_download_processing.py | 26 +- tests/core/test_hardlink.py | 21 +- tests/core/test_oidc_auth.py | 3 + tests/core/test_oidc_integration.py | 237 ++--- tests/core/test_oidc_routes.py | 364 +++---- tests/core/test_per_user_downloads.py | 89 +- tests/core/test_processing_integration.py | 6 +- tests/core/test_user_db.py | 70 +- .../test_orchestrator_user_output_mode.py | 86 ++ tests/e2e/test_auth_endpoints.py | 123 ++- tests/e2e/test_proxy_auth_middleware.py | 67 +- 64 files changed, 5775 insertions(+), 2816 deletions(-) create mode 100644 shelfmark/config/migrations.py create mode 100644 shelfmark/config/security_handlers.py create mode 100644 shelfmark/core/admin_settings_routes.py create mode 100644 shelfmark/core/auth_modes.py create mode 100644 shelfmark/core/cwa_user_sync.py create mode 100644 shelfmark/core/external_user_linking.py delete mode 100644 src/frontend/src/components/EmailRecipientModal.tsx create mode 100644 src/frontend/src/components/settings/shared/SettingsSubpage.tsx create mode 100644 src/frontend/src/components/settings/users/UserAuthSourceBadge.tsx create mode 100644 src/frontend/src/components/settings/users/UserCard.tsx create mode 100644 src/frontend/src/components/settings/users/UserListView.tsx create mode 100644 src/frontend/src/components/settings/users/UserOverridesSection.tsx create mode 100644 src/frontend/src/components/settings/users/UserOverridesView.tsx create mode 100644 src/frontend/src/components/settings/users/index.ts create mode 100644 src/frontend/src/components/settings/users/types.ts create mode 100644 src/frontend/src/components/settings/users/useUserForm.ts create mode 100644 src/frontend/src/components/settings/users/useUserMutations.ts create mode 100644 src/frontend/src/components/settings/users/useUsersFetch.ts create mode 100644 src/frontend/src/components/settings/users/useUsersPanelState.ts create mode 100644 tests/core/test_config_user_overrides.py create mode 100644 tests/core/test_cwa_user_sync.py create mode 100644 tests/download/test_orchestrator_user_output_mode.py diff --git a/requirements-base.txt b/requirements-base.txt index 589d648..28cda0d 100644 --- a/requirements-base.txt +++ b/requirements-base.txt @@ -14,3 +14,4 @@ emoji rarfile qbittorrent-api transmission-rpc +authlib>=1.6.6,<1.7 diff --git a/shelfmark/config/migrations.py b/shelfmark/config/migrations.py new file mode 100644 index 0000000..77f8644 --- /dev/null +++ b/shelfmark/config/migrations.py @@ -0,0 +1,123 @@ +"""Configuration migration helpers.""" + +import json +from typing import Any, Callable + + +_DEPRECATED_SETTINGS_RESTRICTION_KEYS = ( + "PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN", + "CWA_RESTRICT_SETTINGS_TO_ADMIN", + "RESTRICT_SETTINGS_TO_ADMIN", +) + + +def _as_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + +def _pick_legacy_settings_restriction(config: dict[str, Any]) -> bool | None: + """Pick the best legacy admin-restriction value to migrate.""" + auth_method = str(config.get("AUTH_METHOD", "")).strip().lower() + + if ( + auth_method == "proxy" + and "PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN" in config + ): + return _as_bool(config.get("PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN")) + + if auth_method == "cwa" and "CWA_RESTRICT_SETTINGS_TO_ADMIN" in config: + return _as_bool(config.get("CWA_RESTRICT_SETTINGS_TO_ADMIN")) + + if "RESTRICT_SETTINGS_TO_ADMIN" in config: + return _as_bool(config.get("RESTRICT_SETTINGS_TO_ADMIN")) + + if "PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN" in config: + return _as_bool(config.get("PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN")) + + if "CWA_RESTRICT_SETTINGS_TO_ADMIN" in config: + return _as_bool(config.get("CWA_RESTRICT_SETTINGS_TO_ADMIN")) + + return None + + +def migrate_security_settings( + *, + load_security_config: Callable[[], dict[str, Any]], + load_users_config: Callable[[], dict[str, Any]], + save_users_config: Callable[[dict[str, Any]], None], + ensure_config_dir: Callable[[], None], + get_config_path: Callable[[], Any], + sync_builtin_admin_user: Callable[[str, str], None], + logger: Any, +) -> None: + """Migrate legacy security keys and sync builtin admin credentials.""" + try: + config = load_security_config() + users_config = load_users_config() + migrated_security = False + migrated_users = False + + if "USE_CWA_AUTH" in config: + old_value = config.pop("USE_CWA_AUTH") + if "AUTH_METHOD" not in config: + if old_value: + config["AUTH_METHOD"] = "cwa" + logger.info("Migrated USE_CWA_AUTH=True to AUTH_METHOD='cwa'") + else: + if config.get("BUILTIN_USERNAME") and config.get("BUILTIN_PASSWORD_HASH"): + config["AUTH_METHOD"] = "builtin" + logger.info("Migrated USE_CWA_AUTH=False to AUTH_METHOD='builtin'") + else: + config["AUTH_METHOD"] = "none" + logger.info("Migrated USE_CWA_AUTH=False to AUTH_METHOD='none'") + migrated_security = True + else: + logger.info("Removed deprecated USE_CWA_AUTH setting (AUTH_METHOD already exists)") + migrated_security = True + + if "RESTRICT_SETTINGS_TO_ADMIN" not in users_config: + legacy_restrict = _pick_legacy_settings_restriction(config) + if legacy_restrict is not None: + save_users_config({"RESTRICT_SETTINGS_TO_ADMIN": legacy_restrict}) + migrated_users = True + logger.info( + "Migrated legacy settings-admin restriction to users.RESTRICT_SETTINGS_TO_ADMIN=" + f"{legacy_restrict}" + ) + + for deprecated_key in _DEPRECATED_SETTINGS_RESTRICTION_KEYS: + if deprecated_key in config: + config.pop(deprecated_key, None) + migrated_security = True + logger.info(f"Removed deprecated security setting: {deprecated_key}") + + try: + sync_builtin_admin_user( + config.get("BUILTIN_USERNAME", ""), + config.get("BUILTIN_PASSWORD_HASH", ""), + ) + except Exception as exc: + logger.error( + "Failed to sync builtin credentials to users database during migration: " + f"{exc}" + ) + + if migrated_security: + ensure_config_dir() + config_path = get_config_path() + with open(config_path, "w") as f: + json.dump(config, f, indent=2) + logger.info("Security settings migration completed successfully") + elif migrated_users: + logger.info("Users settings migration completed successfully") + else: + logger.debug("No security settings migration needed") + + except FileNotFoundError: + logger.debug("No existing security config file found - nothing to migrate") + except Exception as exc: + logger.error(f"Failed to migrate security settings: {exc}") diff --git a/shelfmark/config/security.py b/shelfmark/config/security.py index e29c599..a8ab233 100644 --- a/shelfmark/config/security.py +++ b/shelfmark/config/security.py @@ -1,9 +1,14 @@ """Authentication settings registration.""" -from typing import Any, Dict +from typing import Any, Dict, Callable from werkzeug.security import generate_password_hash +from shelfmark.config.migrations import migrate_security_settings +from shelfmark.config.security_handlers import ( + on_save_security, + test_oidc_connection, +) from shelfmark.core.logger import setup_logger from shelfmark.core.settings_registry import ( register_settings, @@ -16,200 +21,61 @@ from shelfmark.core.settings_registry import ( ActionButton, TagListField, ) +from shelfmark.core.user_db import sync_builtin_admin_user logger = setup_logger(__name__) +def _auth_condition(auth_method: str) -> dict[str, str]: + return {"field": "AUTH_METHOD", "value": auth_method} + + +def _ui_field(factory: Callable[..., Any], **kwargs: Any) -> Any: + return factory(env_supported=False, **kwargs) + + +def _auth_ui_field(factory: Callable[..., Any], auth_method: str, **kwargs: Any) -> Any: + return _ui_field(factory, show_when=_auth_condition(auth_method), **kwargs) + + def _migrate_security_settings() -> None: - import json - from shelfmark.core.settings_registry import _get_config_file_path, _ensure_config_dir + from shelfmark.core.settings_registry import ( + _get_config_file_path, + _ensure_config_dir, + save_config_file, + ) - try: - config = load_config_file("security") - migrated = False - - # Migrate USE_CWA_AUTH to AUTH_METHOD - if "USE_CWA_AUTH" in config: - old_value = config.pop("USE_CWA_AUTH") - - # Only set AUTH_METHOD if it doesn't already exist - if "AUTH_METHOD" not in config: - if old_value: - config["AUTH_METHOD"] = "cwa" - logger.info("Migrated USE_CWA_AUTH=True to AUTH_METHOD='cwa'") - else: - # If USE_CWA_AUTH was False, determine auth method from credentials - if config.get("BUILTIN_USERNAME") and config.get("BUILTIN_PASSWORD_HASH"): - config["AUTH_METHOD"] = "builtin" - logger.info("Migrated USE_CWA_AUTH=False to AUTH_METHOD='builtin'") - else: - config["AUTH_METHOD"] = "none" - logger.info("Migrated USE_CWA_AUTH=False to AUTH_METHOD='none'") - migrated = True - else: - logger.info("Removed deprecated USE_CWA_AUTH setting (AUTH_METHOD already exists)") - migrated = True - - # Migrate RESTRICT_SETTINGS_TO_ADMIN to CWA_RESTRICT_SETTINGS_TO_ADMIN - if "RESTRICT_SETTINGS_TO_ADMIN" in config: - old_value = config.pop("RESTRICT_SETTINGS_TO_ADMIN") - - # Only migrate if new key doesn't exist - if "CWA_RESTRICT_SETTINGS_TO_ADMIN" not in config: - config["CWA_RESTRICT_SETTINGS_TO_ADMIN"] = old_value - logger.info(f"Migrated RESTRICT_SETTINGS_TO_ADMIN={old_value} to CWA_RESTRICT_SETTINGS_TO_ADMIN={old_value}") - migrated = True - else: - logger.info("Removed deprecated RESTRICT_SETTINGS_TO_ADMIN setting (CWA_RESTRICT_SETTINGS_TO_ADMIN already exists)") - migrated = True - - # Save config if any migrations occurred - if migrated: - _ensure_config_dir("security") - config_path = _get_config_file_path("security") - with open(config_path, 'w') as f: - json.dump(config, f, indent=2) - logger.info("Security settings migration completed successfully") - else: - logger.debug("No security settings migration needed") - - except FileNotFoundError: - logger.debug("No existing security config file found - nothing to migrate") - except Exception as e: - logger.error(f"Failed to migrate security settings: {e}") + migrate_security_settings( + load_security_config=lambda: load_config_file("security"), + load_users_config=lambda: load_config_file("users"), + save_users_config=lambda values: save_config_file("users", values), + ensure_config_dir=lambda: _ensure_config_dir("security"), + get_config_path=lambda: _get_config_file_path("security"), + sync_builtin_admin_user=sync_builtin_admin_user, + logger=logger, + ) -def _clear_builtin_credentials() -> Dict[str, Any]: - """Clear built-in credentials to allow public access.""" - import json - from shelfmark.core.settings_registry import _get_config_file_path, _ensure_config_dir - - try: - config = load_config_file("security") - config.pop("BUILTIN_USERNAME", None) - config.pop("BUILTIN_PASSWORD_HASH", None) - - _ensure_config_dir("security") - config_path = _get_config_file_path("security") - with open(config_path, 'w') as f: - json.dump(config, f, indent=2) - - logger.info("Cleared credentials") - return {"success": True, "message": "Credentials cleared. The app is now publicly accessible."} - - except Exception as e: - logger.error(f"Failed to clear credentials: {e}") - return {"success": False, "message": f"Failed to clear credentials: {str(e)}"} - def _on_save_security(values: Dict[str, Any]) -> Dict[str, Any]: - """ - Custom save handler for security settings. - - Handles password validation and hashing: - - If new password is provided, validate confirmation and hash it - - If password fields are empty, preserve existing hash - - Never store raw passwords - - Ensure username is present if password is set - - Returns: - Dict with processed values to save and any validation errors. - """ - # If switching to OIDC, ensure a local admin exists as fallback - if values.get("AUTH_METHOD") == "oidc": - import os - from shelfmark.core.user_db import UserDB - - db_path = os.path.join(os.environ.get("CONFIG_DIR", "/config"), "users.db") - udb = UserDB(db_path) - udb.initialize() - users = udb.list_users() - has_local_admin = any( - u.get("password_hash") and u.get("role") == "admin" - for u in users - ) - if not has_local_admin: - return { - "error": True, - "message": ( - "Create a local admin account first (Users tab) before enabling OIDC. " - "This ensures you can still log in with a password if SSO is unavailable." - ), - "values": values, - } - - password = values.get("BUILTIN_PASSWORD", "") - password_confirm = values.get("BUILTIN_PASSWORD_CONFIRM", "") - - # Remove raw password fields - they should never be persisted - values.pop("BUILTIN_PASSWORD", None) - values.pop("BUILTIN_PASSWORD_CONFIRM", None) - - # If password is provided, validate and hash it - if password: - if not values.get("BUILTIN_USERNAME"): - return { - "error": True, - "message": "Username cannot be empty", - "values": values - } - - if password != password_confirm: - return { - "error": True, - "message": "Passwords do not match", - "values": values - } - - if len(password) < 4: - return { - "error": True, - "message": "Password must be at least 4 characters", - "values": values - } - - # Hash the password - values["BUILTIN_PASSWORD_HASH"] = generate_password_hash(password) - logger.info("Password hash updated") - - # If no password provided but username is being set, preserve existing hash - elif "BUILTIN_USERNAME" in values: - existing = load_config_file("security") - if "BUILTIN_PASSWORD_HASH" in existing: - values["BUILTIN_PASSWORD_HASH"] = existing["BUILTIN_PASSWORD_HASH"] - - return {"error": False, "values": values} + return on_save_security( + values, + load_security_config=lambda: load_config_file("security"), + hash_password=generate_password_hash, + sync_builtin_admin_user=sync_builtin_admin_user, + logger=logger, + ) def _test_oidc_connection() -> Dict[str, Any]: - """Test OIDC connection by fetching the discovery document.""" - import requests - - try: - config = load_config_file("security") - discovery_url = config.get("OIDC_DISCOVERY_URL", "") - if not discovery_url: - return {"success": False, "message": "Discovery URL is not configured."} - - resp = requests.get(discovery_url, timeout=10) - resp.raise_for_status() - doc = resp.json() - - # Validate required fields - required = ["issuer", "authorization_endpoint", "token_endpoint"] - missing = [f for f in required if f not in doc] - if missing: - return {"success": False, "message": f"Discovery document missing fields: {', '.join(missing)}"} - - return {"success": True, "message": f"Connected to {doc['issuer']}"} - - except Exception as e: - logger.error(f"OIDC connection test failed: {e}") - return {"success": False, "message": f"Connection failed: {str(e)}"} + return test_oidc_connection( + load_security_config=lambda: load_config_file("security"), + logger=logger, + ) @register_settings("security", "Security", icon="shield", order=5) -def security_settings(): +def security_settings(): """Security and authentication settings.""" from shelfmark.config.env import CWA_DB_PATH @@ -217,7 +83,7 @@ def security_settings(): auth_method_options = [ {"label": "No Authentication", "value": "none"}, - {"label": "Username/Password", "value": "builtin"}, + {"label": "Local", "value": "builtin"}, {"label": "Proxy Authentication", "value": "proxy"}, {"label": "OIDC (OpenID Connect)", "value": "oidc"}, ] @@ -237,197 +103,151 @@ def security_settings(): default="none", env_supported=False, ), - TextField( - key="BUILTIN_USERNAME", - label="Username", - description="Set a username and password to require login. Leave both empty for public access.", - placeholder="Enter username", - env_supported=False, - show_when={"field": "AUTH_METHOD", "value": "builtin"}, - ), - PasswordField( - key="BUILTIN_PASSWORD", - label="Set Password", - description="Fill in to set or change the password.", - placeholder="Enter new password", - env_supported=False, - show_when={"field": "AUTH_METHOD", "value": "builtin"}, - ), - PasswordField( - key="BUILTIN_PASSWORD_CONFIRM", - label="Confirm Password", - placeholder="Confirm new password", - env_supported=False, - show_when={"field": "AUTH_METHOD", "value": "builtin"}, - ), ActionButton( - key="clear_credentials", - label="Clear Credentials", - description="Remove login requirement and make the app publicly accessible.", - style="danger", - callback=_clear_builtin_credentials, - show_when={"field": "AUTH_METHOD", "value": "builtin"}, + key="open_users_tab", + label="Go to Users", + description="Configure local users and admin access in the Users tab.", + style="primary", + show_when=_auth_condition("builtin"), ), - TextField( + _auth_ui_field( + TextField, + "proxy", key="PROXY_AUTH_USER_HEADER", label="Proxy Auth User Header", - description=( - "The HTTP header your proxy uses to pass the authenticated username." - ), + description="The HTTP header your proxy uses to pass the authenticated username.", placeholder="e.g. X-Auth-User", default="X-Auth-User", - env_supported=False, - show_when={"field": "AUTH_METHOD", "value": "proxy"}, ), - TextField( + _auth_ui_field( + TextField, + "proxy", key="PROXY_AUTH_LOGOUT_URL", label="Proxy Auth Logout URL", - description=( - "The URL to redirect users to for logging out." - " Leave empty to disable logout functionality." - ), + description="The URL to redirect users to for logging out. Leave empty to disable logout functionality.", placeholder="https://myauth.example.com/logout", default="", - env_supported=False, - show_when={"field": "AUTH_METHOD", "value": "proxy"}, ), - CheckboxField( - key="PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN", - label="Restrict Settings to Admins authenticated via Proxy", - description=( - "Only users in the admin group can access settings." - ), - default=False, - env_supported=False, - show_when={"field": "AUTH_METHOD", "value": "proxy"}, - ), - TextField( + _auth_ui_field( + TextField, + "proxy", key="PROXY_AUTH_ADMIN_GROUP_HEADER", label="Proxy Auth Admin Group Header", - description=( - "The HTTP header your proxy uses to pass the user's groups/roles." - ), + description="Optional: header your proxy uses to pass user groups/roles.", placeholder="e.g. X-Auth-Groups", default="X-Auth-Groups", - env_supported=False, - show_when={"field": "PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN", "value": True}, ), - TextField( + _auth_ui_field( + TextField, + "proxy", key="PROXY_AUTH_ADMIN_GROUP_NAME", - label="Proxy Auth Admin Group Name", - description=( - "The name of the group/role that should have admin access." - ), + label="Proxy Auth Admin Group", + description="Optional: users in this group are treated as admins. Leave blank to skip group-based admin detection.", placeholder="e.g. admins", - default="admins", - env_supported=False, - show_when={"field": "PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN", "value": True}, - ), - CheckboxField( - key="CWA_RESTRICT_SETTINGS_TO_ADMIN", - label="Restrict Settings to Admins authenticated via Calibre-Web", - description=( - "Only users with admin role in Calibre-Web can access settings." - ), - default=False, - env_supported=False, - show_when={"field": "AUTH_METHOD", "value": "cwa"}, - ), - # === OIDC SETTINGS === - TextField( - key="OIDC_DISCOVERY_URL", - label="Discovery URL", - description=( - "OpenID Connect discovery endpoint URL." - " Usually ends with /.well-known/openid-configuration." - ), - placeholder="https://auth.example.com/.well-known/openid-configuration", - required=True, - env_supported=False, - show_when={"field": "AUTH_METHOD", "value": "oidc"}, - ), - TextField( - key="OIDC_CLIENT_ID", - label="Client ID", - description="OAuth2 client ID from your identity provider.", - placeholder="shelfmark", - required=True, - env_supported=False, - show_when={"field": "AUTH_METHOD", "value": "oidc"}, - ), - PasswordField( - key="OIDC_CLIENT_SECRET", - label="Client Secret", - description="OAuth2 client secret from your identity provider.", - required=True, - env_supported=False, - show_when={"field": "AUTH_METHOD", "value": "oidc"}, - ), - TagListField( - key="OIDC_SCOPES", - label="Scopes", - description="OAuth2 scopes to request from the identity provider. Managed automatically: includes essential scopes and the group claim when using admin group authorization.", - default=["openid", "email", "profile"], - env_supported=False, - show_when={"field": "AUTH_METHOD", "value": "oidc"}, - ), - TextField( - key="OIDC_GROUP_CLAIM", - label="Group Claim Name", - description=( - "The name of the claim in the ID token that contains user groups." - ), - placeholder="groups", - default="groups", - env_supported=False, - show_when={"field": "AUTH_METHOD", "value": "oidc"}, - ), - TextField( - key="OIDC_ADMIN_GROUP", - label="Admin Group Name", - description=( - "Users in this group will be given admin access (if enabled below). " - "Leave empty to use database roles only." - ), - placeholder="shelfmark-admins", default="", - env_supported=False, - show_when={"field": "AUTH_METHOD", "value": "oidc"}, ), - CheckboxField( - key="OIDC_USE_ADMIN_GROUP", - label="Use Admin Group for Authorization", - description=( - "When enabled, users in the Admin Group are granted admin access. " - "When disabled, admin access is determined solely by database roles." - ), - default=True, - env_supported=False, - show_when={"field": "AUTH_METHOD", "value": "oidc"}, + ] + + oidc_specs = [ + ( + TextField, + { + "key": "OIDC_DISCOVERY_URL", + "label": "Discovery URL", + "description": "OpenID Connect discovery endpoint URL. Usually ends with /.well-known/openid-configuration.", + "placeholder": "https://auth.example.com/.well-known/openid-configuration", + "required": True, + }, ), - CheckboxField( - key="OIDC_AUTO_PROVISION", - label="Auto-Provision Users", - description=( - "Automatically create a user account on first OIDC login." - " When disabled, users must be pre-created by an admin." - ), - default=True, - env_supported=False, - show_when={"field": "AUTH_METHOD", "value": "oidc"}, + ( + TextField, + { + "key": "OIDC_CLIENT_ID", + "label": "Client ID", + "description": "OAuth2 client ID from your identity provider.", + "placeholder": "shelfmark", + "required": True, + }, ), + ( + PasswordField, + { + "key": "OIDC_CLIENT_SECRET", + "label": "Client Secret", + "description": "OAuth2 client secret from your identity provider.", + "required": True, + }, + ), + ( + TagListField, + { + "key": "OIDC_SCOPES", + "label": "Scopes", + "description": "OAuth2 scopes to request from the identity provider. Managed automatically: includes essential scopes and the group claim when using admin group authorization.", + "default": ["openid", "email", "profile"], + }, + ), + ( + TextField, + { + "key": "OIDC_GROUP_CLAIM", + "label": "Group Claim Name", + "description": "The name of the claim in the ID token that contains user groups.", + "placeholder": "groups", + "default": "groups", + }, + ), + ( + TextField, + { + "key": "OIDC_ADMIN_GROUP", + "label": "Admin Group Name", + "description": "Users in this group will be given admin access (if enabled below). Leave empty to use database roles only.", + "placeholder": "shelfmark-admins", + "default": "", + }, + ), + ( + CheckboxField, + { + "key": "OIDC_USE_ADMIN_GROUP", + "label": "Use Admin Group for Authorization", + "description": "When enabled, users in the Admin Group are granted admin access. When disabled, admin access is determined solely by database roles.", + "default": True, + }, + ), + ( + CheckboxField, + { + "key": "OIDC_AUTO_PROVISION", + "label": "Auto-Provision Users", + "description": "Automatically create a user account on first OIDC login. When disabled, users must be pre-created by an admin.", + "default": True, + }, + ), + ( + TextField, + { + "key": "OIDC_BUTTON_LABEL", + "label": "Login Button Label", + "description": "Custom label for the OIDC sign-in button on the login page.", + "placeholder": "Sign in with OIDC", + "default": "", + }, + ), + ] + fields.extend(_auth_ui_field(factory, "oidc", **spec) for factory, spec in oidc_specs) + fields.append( ActionButton( key="test_oidc", label="Test Connection", description="Fetch the OIDC discovery document and validate configuration.", style="primary", callback=_test_oidc_connection, - show_when={"field": "AUTH_METHOD", "value": "oidc"}, - ), - ] - + show_when=_auth_condition("oidc"), + ) + ) return fields -# Register the on_save handler for this tab register_on_save("security", _on_save_security) diff --git a/shelfmark/config/security_handlers.py b/shelfmark/config/security_handlers.py new file mode 100644 index 0000000..80e4a9d --- /dev/null +++ b/shelfmark/config/security_handlers.py @@ -0,0 +1,87 @@ +"""Operational handlers for security settings (save/actions).""" + +import os +from typing import Any, Callable + +from shelfmark.core.user_db import UserDB + + +_OIDC_LOCKOUT_MESSAGE = "Create a local admin account first (Users tab) before enabling OIDC. This ensures you can still log in with a password if SSO is unavailable." + + +def _has_local_password_admin() -> bool: + root = os.environ.get("CONFIG_DIR", "/config") + user_db = UserDB(os.path.join(root, "users.db")) + user_db.initialize() + return any(user.get("password_hash") and user.get("role") == "admin" for user in user_db.list_users()) + + +def on_save_security( + values: dict[str, Any], + *, + load_security_config: Callable[[], dict[str, Any]], + hash_password: Callable[[str], str], + sync_builtin_admin_user: Callable[[str, str], None], + logger: Any, +) -> dict[str, Any]: + """Validate/process security values before persistence.""" + if values.get("AUTH_METHOD") == "oidc" and not _has_local_password_admin(): + return {"error": True, "message": _OIDC_LOCKOUT_MESSAGE, "values": values} + + password = values.pop("BUILTIN_PASSWORD", "") + password_confirm = values.pop("BUILTIN_PASSWORD_CONFIRM", "") + + if password: + if not values.get("BUILTIN_USERNAME"): + return {"error": True, "message": "Username cannot be empty", "values": values} + if password != password_confirm: + return {"error": True, "message": "Passwords do not match", "values": values} + if len(password) < 4: + return {"error": True, "message": "Password must be at least 4 characters", "values": values} + + values["BUILTIN_PASSWORD_HASH"] = hash_password(password) + logger.info("Password hash updated") + elif "BUILTIN_USERNAME" in values: + existing = load_security_config() + if "BUILTIN_PASSWORD_HASH" in existing: + values["BUILTIN_PASSWORD_HASH"] = existing["BUILTIN_PASSWORD_HASH"] + + if values.get("AUTH_METHOD") == "builtin": + try: + sync_builtin_admin_user( + values.get("BUILTIN_USERNAME", ""), + values.get("BUILTIN_PASSWORD_HASH", ""), + ) + except Exception as exc: + logger.error(f"Failed to sync builtin admin user: {exc}") + return {"error": True, "message": "Failed to create/update local admin user from builtin credentials", "values": values} + + return {"error": False, "values": values} + + +def test_oidc_connection( + *, + load_security_config: Callable[[], dict[str, Any]], + logger: Any, +) -> dict[str, Any]: + """Fetch and validate the configured OIDC discovery document.""" + import requests + + try: + discovery_url = load_security_config().get("OIDC_DISCOVERY_URL", "") + if not discovery_url: + return {"success": False, "message": "Discovery URL is not configured."} + + response = requests.get(discovery_url, timeout=10) + response.raise_for_status() + document = response.json() + + required_fields = ["issuer", "authorization_endpoint", "token_endpoint"] + missing_fields = [field for field in required_fields if field not in document] + if missing_fields: + return {"success": False, "message": f"Discovery document missing fields: {', '.join(missing_fields)}"} + + return {"success": True, "message": f"Connected to {document['issuer']}"} + except Exception as exc: + logger.error(f"OIDC connection test failed: {exc}") + return {"success": False, "message": f"Connection failed: {str(exc)}"} diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index 849d8c2..6379090 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -633,46 +633,20 @@ def _on_save_downloads(values: dict[str, Any]) -> dict[str, Any]: parsed = parseaddr(addr or "")[1] return bool(parsed) and "@" in parsed and parsed == addr - raw_recipients = effective.get("EMAIL_RECIPIENTS", []) - if not isinstance(raw_recipients, list): - return {"error": True, "message": "Email recipients must be a list", "values": values} + # Preferred model: single recipient for global default and per-user override. + raw_recipient = str(effective.get("EMAIL_RECIPIENT", "") or "").strip() - cleaned_recipients: list[dict[str, str]] = [] - seen_nicknames: set[str] = set() - - for entry in raw_recipients: - if not isinstance(entry, dict): - continue - - nickname = str(entry.get("nickname", "") or "").strip() - email = str(entry.get("email", "") or "").strip() - - # Allow incomplete rows in the UI by skipping them. - if not nickname or not email: - continue - - nickname_key = nickname.lower() - if nickname_key in seen_nicknames: - return { - "error": True, - "message": f"Duplicate email recipient nickname: {nickname}", - "values": values, - } - seen_nicknames.add(nickname_key) - - if not _is_plain_email_address(email): - return { - "error": True, - "message": f"Invalid email address for '{nickname}': {email}", - "values": values, - } - - cleaned_recipients.append({"nickname": nickname, "email": email}) - - if not cleaned_recipients: + if not raw_recipient: return { "error": True, - "message": "At least one email recipient is required (Downloads -> Books -> Email).", + "message": "Email recipient is required (Downloads -> Books -> Email Recipient).", + "values": values, + } + + if not _is_plain_email_address(raw_recipient): + return { + "error": True, + "message": "Email recipient must be a valid plain email address.", "values": values, } @@ -748,8 +722,8 @@ def _on_save_downloads(values: dict[str, Any]) -> dict[str, Any]: } # Persist any normalization/coercion for fields that may have been edited this save. - if "EMAIL_RECIPIENTS" in values: - values["EMAIL_RECIPIENTS"] = cleaned_recipients + if "EMAIL_RECIPIENT" in values: + values["EMAIL_RECIPIENT"] = raw_recipient if "EMAIL_SMTP_SECURITY" in values: values["EMAIL_SMTP_SECURITY"] = security if "EMAIL_SMTP_PORT" in values: @@ -795,6 +769,7 @@ def download_settings(): }, ], default="folder", + user_overridable=True, ), TextField( key="DESTINATION", @@ -803,6 +778,7 @@ def download_settings(): default="/books", required=True, env_var="INGEST_DIR", # Legacy env var name for backwards compatibility + user_overridable=True, show_when={ "field": "BOOKS_OUTPUT_MODE", "value": "folder", @@ -904,6 +880,7 @@ def download_settings(): description="Booklore library to upload into.", options=get_booklore_library_options, required=True, + user_overridable=True, show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"}, ), SelectField( @@ -913,6 +890,7 @@ def download_settings(): options=get_booklore_path_options, required=True, filter_by_field="BOOKLORE_LIBRARY_ID", + user_overridable=True, show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"}, ), ActionButton( @@ -929,27 +907,12 @@ def download_settings(): description="Send books as email attachments via SMTP. Audiobooks always use folder mode.", show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"}, ), - TableField( - key="EMAIL_RECIPIENTS", - label="Recipients", - columns=[ - { - "key": "nickname", - "label": "Nickname", - "type": "text", - "placeholder": "eReader", - }, - { - "key": "email", - "label": "Email", - "type": "text", - "placeholder": "device@example.com", - }, - ], - default=[], - add_label="Add Recipient", - empty_message="No recipients configured.", - env_supported=False, + TextField( + key="EMAIL_RECIPIENT", + label="Email Recipient", + description="Email address that should receive downloaded books when Output Mode is Email.", + placeholder="reader@example.com", + user_overridable=True, show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"}, ), NumberField( @@ -1054,8 +1017,9 @@ def download_settings(): TextField( key="DESTINATION_AUDIOBOOK", label="Destination", - description="Leave empty to use Books destination.", + description="Leave empty to use Books destination. Use {User} for per-user folders (e.g. /audiobooks/{User}).", placeholder="/audiobooks", + user_overridable=True, universal_only=True, ), SelectField( diff --git a/shelfmark/config/users_settings.py b/shelfmark/config/users_settings.py index 0710cc4..a8f5dd1 100644 --- a/shelfmark/config/users_settings.py +++ b/shelfmark/config/users_settings.py @@ -6,6 +6,7 @@ that talks to /api/admin/users endpoints. """ from shelfmark.core.settings_registry import ( + CheckboxField, HeadingField, register_settings, ) @@ -16,8 +17,17 @@ def users_settings(): """User management tab - rendered as a custom component on the frontend.""" return [ HeadingField( - key="users_heading", - title="User Accounts", - description="Manage user accounts for multi-user authentication.", + key="users_access_heading", + title="Options", + ), + CheckboxField( + key="RESTRICT_SETTINGS_TO_ADMIN", + label="Restrict Settings and Onboarding to Admins", + description=( + "When enabled, only admin users can access Settings and Onboarding. " + "When disabled, any authenticated user can access them." + ), + default=True, + env_supported=False, ), ] diff --git a/shelfmark/core/admin_routes.py b/shelfmark/core/admin_routes.py index fd3cc73..a1b522e 100644 --- a/shelfmark/core/admin_routes.py +++ b/shelfmark/core/admin_routes.py @@ -5,6 +5,9 @@ All endpoints require admin session. """ from functools import wraps +import os +import sqlite3 +from typing import Any from flask import Flask, jsonify, request, session from werkzeug.security import generate_password_hash @@ -13,26 +16,64 @@ from shelfmark.config.booklore_settings import ( get_booklore_library_options, get_booklore_path_options, ) +from shelfmark.config.env import CWA_DB_PATH +from shelfmark.core.admin_settings_routes import ( + register_admin_settings_routes, + validate_user_settings, +) +from shelfmark.core.auth_modes import ( + AUTH_SOURCE_BUILTIN, + AUTH_SOURCE_CWA, + AUTH_SOURCE_OIDC, + AUTH_SOURCE_PROXY, + determine_auth_mode, + has_local_password_admin, + normalize_auth_source, +) +from shelfmark.core.cwa_user_sync import sync_cwa_users_from_rows from shelfmark.core.logger import setup_logger from shelfmark.core.settings_registry import load_config_file from shelfmark.core.user_db import UserDB logger = setup_logger(__name__) -_DOWNLOAD_DEFAULTS = { - "BOOKS_OUTPUT_MODE": "folder", - "DESTINATION": "/books", - "BOOKLORE_LIBRARY_ID": "", - "BOOKLORE_PATH_ID": "", - "EMAIL_RECIPIENTS": [], -} + +def _get_user_edit_capabilities( + user: dict[str, Any], + security_config: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Return backend-authored capability flags for the user edit form.""" + auth_source = normalize_auth_source( + user.get("auth_source"), + user.get("oidc_subject"), + ) + if security_config is None and auth_source == AUTH_SOURCE_OIDC: + security_config = load_config_file("security") + + oidc_use_admin_group = bool((security_config or {}).get("OIDC_USE_ADMIN_GROUP", True)) + role_managed_by_oidc_group = auth_source == AUTH_SOURCE_OIDC and oidc_use_admin_group + can_edit_role = auth_source == AUTH_SOURCE_BUILTIN or ( + auth_source == AUTH_SOURCE_OIDC and not role_managed_by_oidc_group + ) + + return { + "authSource": auth_source, + "canSetPassword": auth_source == AUTH_SOURCE_BUILTIN, + "canEditRole": can_edit_role, + "canEditEmail": auth_source in {AUTH_SOURCE_BUILTIN, AUTH_SOURCE_PROXY}, + "canEditDisplayName": auth_source != AUTH_SOURCE_OIDC, + } def _get_auth_mode(): """Get current auth mode from config.""" try: config = load_config_file("security") - return config.get("AUTH_METHOD", "none") + return determine_auth_mode( + config, + CWA_DB_PATH, + has_local_admin=has_local_password_admin(), + ) except Exception: return "none" @@ -57,8 +98,67 @@ def _require_admin(f): def _sanitize_user(user: dict) -> dict: """Remove sensitive fields from user dict before returning to client.""" - user.pop("password_hash", None) - return user + sanitized = dict(user) + sanitized.pop("password_hash", None) + return sanitized + + +def _oidc_role_management_message(security_config: dict[str, Any]) -> str: + admin_group = security_config.get("OIDC_ADMIN_GROUP", "") + if admin_group: + return ( + "Admin roles for OIDC users are managed by the " + f"'{admin_group}' group in your identity provider" + ) + return ( + "Disable 'Use Admin Group for Authorization' in security settings " + "to manage roles manually" + ) + + +def _is_user_active(user: dict[str, Any], auth_method: str) -> bool: + """Determine whether a user can authenticate in the current auth mode.""" + source = normalize_auth_source(user.get("auth_source"), user.get("oidc_subject")) + if source == AUTH_SOURCE_BUILTIN: + return auth_method in (AUTH_SOURCE_BUILTIN, AUTH_SOURCE_OIDC) + return source == auth_method + + +def _serialize_user( + user: dict[str, Any], + auth_method: str, + security_config: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Sanitize and enrich a user payload for API responses.""" + payload = _sanitize_user(user) + payload["auth_source"] = normalize_auth_source( + payload.get("auth_source"), + payload.get("oidc_subject"), + ) + payload["is_active"] = _is_user_active(payload, auth_method) + payload["edit_capabilities"] = _get_user_edit_capabilities( + payload, + security_config=security_config, + ) + return payload + + +def _sync_all_cwa_users(user_db: UserDB) -> dict[str, int]: + """Sync all users from the Calibre-Web database into users.db.""" + if not CWA_DB_PATH or not CWA_DB_PATH.exists(): + raise FileNotFoundError("Calibre-Web database is not available") + + db_path = os.fspath(CWA_DB_PATH) + db_uri = f"file:{db_path}?mode=ro&immutable=1" + conn = sqlite3.connect(db_uri, uri=True) + try: + cur = conn.cursor() + cur.execute("SELECT name, role, email FROM user") + rows = cur.fetchall() + finally: + conn.close() + + return sync_cwa_users_from_rows(user_db, rows) def register_admin_routes(app: Flask, user_db: UserDB) -> None: @@ -69,13 +169,19 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None: def admin_list_users(): """List all users.""" users = user_db.list_users() - return jsonify([_sanitize_user(u) for u in users]) + auth_mode = _get_auth_mode() + security_config = load_config_file("security") + return jsonify([ + _serialize_user(u, auth_mode, security_config=security_config) + for u in users + ]) @app.route("/api/admin/users", methods=["POST"]) @_require_admin def admin_create_user(): """Create a new user with password authentication.""" data = request.get_json() or {} + auth_mode = _get_auth_mode() username = (data.get("username") or "").strip() password = data.get("password", "") @@ -83,6 +189,15 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None: display_name = (data.get("display_name") or "").strip() or None role = data.get("role", "user") + if auth_mode in {AUTH_SOURCE_PROXY, AUTH_SOURCE_CWA}: + return jsonify({ + "error": "Local user creation is disabled in this authentication mode", + "message": ( + "Users are provisioned by your external authentication source. " + "Switch to builtin or OIDC mode to create local users." + ), + }), 400 + if not username: return jsonify({"error": "Username is required"}), 400 if not password or len(password) < 4: @@ -105,12 +220,23 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None: password_hash=password_hash, email=email, display_name=display_name, + auth_source=AUTH_SOURCE_BUILTIN, role=role, ) except ValueError: return jsonify({"error": "Username already exists"}), 409 - logger.info(f"Admin created user: {username} (role={role})") - return jsonify(_sanitize_user(user)), 201 + logger.info( + "Shelfmark user created " + f"(source=manual_admin_create, created_by={session.get('user_id', 'unknown')}, " + f"username={username}, role={role}, auth_source={AUTH_SOURCE_BUILTIN})" + ) + return jsonify( + _serialize_user( + user, + _get_auth_mode(), + security_config=load_config_file("security"), + ) + ), 201 @app.route("/api/admin/users/", methods=["GET"]) @_require_admin @@ -120,7 +246,11 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None: if not user: return jsonify({"error": "User not found"}), 404 - result = _sanitize_user(user) + result = _serialize_user( + user, + _get_auth_mode(), + security_config=load_config_file("security"), + ) result["settings"] = user_db.get_user_settings(user_id) return jsonify(result) @@ -133,10 +263,21 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None: return jsonify({"error": "User not found"}), 404 data = request.get_json() or {} + security_config = load_config_file("security") + auth_source = normalize_auth_source( + user.get("auth_source"), + user.get("oidc_subject"), + ) + capabilities = _get_user_edit_capabilities(user, security_config=security_config) # Handle optional password update password = data.get("password", "") if password: + if not capabilities["canSetPassword"]: + return jsonify({ + "error": f"Cannot set password for {auth_source.upper()} users", + "message": "Password authentication is only available for local users.", + }), 400 if len(password) < 4: return jsonify({"error": "Password must be at least 4 characters"}), 400 user_db.update_user(user_id, password_hash=generate_password_hash(password)) @@ -150,24 +291,45 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None: if "role" in user_fields and user_fields["role"] not in ("admin", "user"): return jsonify({"error": "Role must be 'admin' or 'user'"}), 400 - # Prevent changing OIDC user role when group-based auth is enabled - if "role" in user_fields and user.get("oidc_subject") and user_fields["role"] != user.get("role"): - security_config = load_config_file("security") - use_admin_group = security_config.get("OIDC_USE_ADMIN_GROUP", True) - if use_admin_group: - admin_group = security_config.get("OIDC_ADMIN_GROUP", "") - msg = ( - f"Admin roles for OIDC users are managed by the '{admin_group}' group in your identity provider" - if admin_group - else "Disable 'Use Admin Group for Authorization' in security settings to manage roles manually" - ) + role_changed = "role" in user_fields and user_fields["role"] != user.get("role") + email_changed = "email" in user_fields and user_fields["email"] != user.get("email") + display_name_changed = ( + "display_name" in user_fields + and user_fields["display_name"] != user.get("display_name") + ) + + if role_changed and not capabilities["canEditRole"]: + if auth_source == AUTH_SOURCE_OIDC: return jsonify({ "error": "Cannot change role for OIDC user when group-based authorization is enabled", - "message": msg, + "message": _oidc_role_management_message(security_config), }), 400 + return jsonify({ + "error": f"Cannot change role for {auth_source.upper()} users", + "message": "Role is managed by the external authentication source.", + }), 400 + + if email_changed and not capabilities["canEditEmail"]: + if auth_source == AUTH_SOURCE_CWA: + return jsonify({ + "error": "Cannot change email for CWA users", + "message": "Email is synced from Calibre-Web.", + }), 400 + + return jsonify({ + "error": "Cannot change email for OIDC users", + "message": "Email is managed by your identity provider.", + }), 400 + + if display_name_changed and not capabilities["canEditDisplayName"]: + return jsonify({ + "error": "Cannot change display name for OIDC users", + "message": "Display name is managed by your identity provider.", + }), 400 + # Prevent demoting the last admin - if "role" in user_fields and user_fields["role"] != "admin": + if role_changed and user_fields["role"] != "admin": if user.get("role") == "admin": other_admins = [ u for u in user_db.list_users() @@ -176,50 +338,80 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None: if not other_admins: return jsonify({"error": "Cannot remove admin role from the last admin account"}), 400 + # Avoid unnecessary writes for no-op field updates. + for field in ("role", "email", "display_name"): + if field in user_fields and user_fields[field] == user.get(field): + user_fields.pop(field) + if user_fields: user_db.update_user(user_id, **user_fields) # Update per-user settings - if "settings" in data and isinstance(data["settings"], dict): - user_db.set_user_settings(user_id, data["settings"]) + if "settings" in data: + if not isinstance(data["settings"], dict): + return jsonify({"error": "Settings must be an object"}), 400 + + validated_settings, validation_errors = validate_user_settings(data["settings"]) + if validation_errors: + return jsonify({ + "error": "Invalid settings payload", + "details": validation_errors, + }), 400 + + user_db.set_user_settings(user_id, validated_settings) + # Ensure runtime reads see updated per-user overrides immediately. + try: + from shelfmark.core.config import config as app_config + app_config.refresh() + except Exception: + pass updated = user_db.get_user(user_id=user_id) - result = _sanitize_user(updated) + result = _serialize_user( + updated, + _get_auth_mode(), + security_config=security_config, + ) result["settings"] = user_db.get_user_settings(user_id) logger.info(f"Admin updated user {user_id}") return jsonify(result) - @app.route("/api/admin/download-defaults", methods=["GET"]) + @app.route("/api/admin/users/sync-cwa", methods=["POST"]) @_require_admin - def admin_download_defaults(): - """Return global download settings relevant to per-user overrides.""" - config = load_config_file("downloads") - keys = [ - "BOOKS_OUTPUT_MODE", - "DESTINATION", - "BOOKLORE_LIBRARY_ID", - "BOOKLORE_PATH_ID", - "EMAIL_RECIPIENTS", - ] - defaults = {k: config.get(k, _DOWNLOAD_DEFAULTS.get(k)) for k in keys} + def admin_sync_cwa_users(): + """Manually sync users from Calibre-Web into users.db.""" + auth_mode = _get_auth_mode() + if auth_mode != AUTH_SOURCE_CWA: + return jsonify({ + "error": "CWA sync is only available when CWA authentication is enabled", + }), 400 - # Include OIDC settings for UI warnings (e.g., when admin tries to set OIDC user role) - security_config = load_config_file("security") - defaults["OIDC_ADMIN_GROUP"] = security_config.get("OIDC_ADMIN_GROUP", "") - defaults["OIDC_USE_ADMIN_GROUP"] = security_config.get("OIDC_USE_ADMIN_GROUP", True) - defaults["OIDC_AUTO_PROVISION"] = security_config.get("OIDC_AUTO_PROVISION", True) + try: + summary = _sync_all_cwa_users(user_db) + except FileNotFoundError: + return jsonify({ + "error": "Calibre-Web database is not available", + "message": "Verify app.db is mounted and readable at /auth/app.db.", + }), 503 + except Exception as exc: + logger.error(f"Failed to sync CWA users: {exc}") + return jsonify({ + "error": "Failed to sync users from Calibre-Web", + }), 500 - return jsonify(defaults) - - @app.route("/api/admin/booklore-options", methods=["GET"]) - @_require_admin - def admin_booklore_options(): - """Return available BookLore library and path options.""" + message = ( + f"Synced {summary['total']} CWA users " + f"({summary['created']} created, {summary['updated']} updated)." + ) + logger.info(message) return jsonify({ - "libraries": get_booklore_library_options(), - "paths": get_booklore_path_options(), + "success": True, + "message": message, + **summary, }) + register_admin_settings_routes(app, user_db, _require_admin) + @app.route("/api/admin/users/", methods=["DELETE"]) @_require_admin def admin_delete_user(user_id): @@ -232,6 +424,17 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None: if not user: return jsonify({"error": "User not found"}), 404 + auth_mode = _get_auth_mode() + auth_source = normalize_auth_source( + user.get("auth_source"), + user.get("oidc_subject"), + ) + if auth_source in {AUTH_SOURCE_PROXY, AUTH_SOURCE_CWA} and auth_source == auth_mode: + return jsonify({ + "error": f"Cannot delete active {auth_source.upper()} users", + "message": f"{auth_source.upper()} users are automatically re-provisioned on login.", + }), 400 + # Prevent deleting the last local admin if user.get("role") == "admin" and user.get("password_hash"): local_admins = [ diff --git a/shelfmark/core/admin_settings_routes.py b/shelfmark/core/admin_settings_routes.py new file mode 100644 index 0000000..3e7ec22 --- /dev/null +++ b/shelfmark/core/admin_settings_routes.py @@ -0,0 +1,196 @@ +"""Admin settings-introspection routes and settings validation helpers.""" + +from typing import Any, Callable + +from flask import Flask, jsonify, request + +from shelfmark.core.settings_registry import load_config_file +from shelfmark.core.user_db import UserDB + + +def _get_settings_registry(): + # Ensure settings modules are loaded before reading registry metadata. + import shelfmark.config.settings # noqa: F401 + import shelfmark.config.security # noqa: F401 + from shelfmark.core import settings_registry + + return settings_registry + + +def _get_ordered_user_overridable_fields(tab_name: str) -> list[tuple[str, Any]]: + settings_registry = _get_settings_registry() + tab = settings_registry.get_settings_tab(tab_name) + if not tab: + return [] + overridable_map = settings_registry.get_user_overridable_fields(tab_name=tab_name) + return [(field.key, field) for field in tab.fields if field.key in overridable_map] + + +def validate_user_settings(settings: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: + settings_registry = _get_settings_registry() + field_map = settings_registry.get_settings_field_map() + overridable_map = settings_registry.get_user_overridable_fields() + + valid: dict[str, Any] = {} + errors: list[str] = [] + for key, value in settings.items(): + if key not in field_map: + errors.append(f"Unknown setting: {key}") + elif key not in overridable_map: + errors.append(f"Setting not user-overridable: {key}") + else: + valid[key] = value + + return valid, errors + + +def register_admin_settings_routes( + app: Flask, + user_db: UserDB, + require_admin: Callable[[Callable[..., Any]], Callable[..., Any]], +) -> None: + @app.route("/api/admin/download-defaults", methods=["GET"]) + @require_admin + def admin_download_defaults(): + config = load_config_file("downloads") + defaults = { + key: ("" if (value := config.get(key, field.default)) is None else value) + for key, field in _get_ordered_user_overridable_fields("downloads") + } + + security_config = load_config_file("security") + defaults["OIDC_ADMIN_GROUP"] = security_config.get("OIDC_ADMIN_GROUP", "") + defaults["OIDC_USE_ADMIN_GROUP"] = security_config.get("OIDC_USE_ADMIN_GROUP", True) + defaults["OIDC_AUTO_PROVISION"] = security_config.get("OIDC_AUTO_PROVISION", True) + return jsonify(defaults) + + @app.route("/api/admin/booklore-options", methods=["GET"]) + @require_admin + def admin_booklore_options(): + from shelfmark.core import admin_routes + + return jsonify({ + "libraries": admin_routes.get_booklore_library_options(), + "paths": admin_routes.get_booklore_path_options(), + }) + + @app.route("/api/admin/users//delivery-preferences", methods=["GET"]) + @require_admin + def admin_get_delivery_preferences(user_id): + user = user_db.get_user(user_id=user_id) + if not user: + return jsonify({"error": "User not found"}), 404 + + from shelfmark.core import settings_registry + from shelfmark.core.config import config as app_config + + ordered_fields = _get_ordered_user_overridable_fields("downloads") + if not ordered_fields: + return jsonify({"error": "Downloads settings tab not found"}), 500 + + download_config = load_config_file("downloads") + user_settings = user_db.get_user_settings(user_id) + ordered_keys = [key for key, _ in ordered_fields] + + fields_payload: list[dict[str, Any]] = [] + global_values: dict[str, Any] = {} + effective: dict[str, dict[str, Any]] = {} + + for key, field in ordered_fields: + serialized = settings_registry.serialize_field(field, "downloads", include_value=False) + serialized["fromEnv"] = bool(field.env_supported and settings_registry.is_value_from_env(field)) + fields_payload.append(serialized) + + global_values[key] = app_config.get(key, field.default) + + source = "default" + value = app_config.get(key, field.default, user_id=user_id) + if field.env_supported and settings_registry.is_value_from_env(field): + source = "env_var" + elif key in user_settings and user_settings[key] is not None: + source = "user_override" + value = user_settings[key] + elif key in download_config: + source = "global_config" + + effective[key] = {"value": value, "source": source} + + user_overrides = { + key: user_settings[key] + for key in ordered_keys + if key in user_settings and user_settings[key] is not None + } + + return jsonify({ + "tab": "downloads", + "keys": ordered_keys, + "fields": fields_payload, + "globalValues": global_values, + "userOverrides": user_overrides, + "effective": effective, + }) + + @app.route("/api/admin/settings/overrides-summary", methods=["GET"]) + @require_admin + def admin_settings_overrides_summary(): + settings_registry = _get_settings_registry() + + tab_name = (request.args.get("tab") or "downloads").strip() + if not settings_registry.get_settings_tab(tab_name): + return jsonify({"error": f"Unknown settings tab: {tab_name}"}), 404 + + overridable_keys = list(settings_registry.get_user_overridable_fields(tab_name=tab_name)) + keys_payload: dict[str, dict[str, Any]] = {} + + for user_record in user_db.list_users(): + user_settings = user_db.get_user_settings(user_record["id"]) + if not isinstance(user_settings, dict): + continue + + for key in overridable_keys: + if key not in user_settings or user_settings[key] is None: + continue + entry = keys_payload.setdefault(key, {"count": 0, "users": []}) + entry["users"].append({ + "userId": user_record["id"], + "username": user_record["username"], + "value": user_settings[key], + }) + + for summary in keys_payload.values(): + summary["count"] = len(summary["users"]) + + return jsonify({"tab": tab_name, "keys": keys_payload}) + + @app.route("/api/admin/users//effective-settings", methods=["GET"]) + @require_admin + def admin_get_effective_settings(user_id): + user = user_db.get_user(user_id=user_id) + if not user: + return jsonify({"error": "User not found"}), 404 + + from shelfmark.core.config import config as app_config + from shelfmark.core.settings_registry import is_value_from_env + + field_map = _get_settings_registry().get_user_overridable_fields() + user_settings = user_db.get_user_settings(user_id) + tab_config_cache: dict[str, dict[str, Any]] = {} + effective: dict[str, dict[str, Any]] = {} + + for key, (field, tab_name) in sorted(field_map.items()): + value = app_config.get(key, field.default, user_id=user_id) + source = "default" + + if field.env_supported and is_value_from_env(field): + source = "env_var" + elif key in user_settings and user_settings[key] is not None: + source = "user_override" + value = user_settings[key] + else: + tab_config = tab_config_cache.setdefault(tab_name, load_config_file(tab_name)) + if key in tab_config: + source = "global_config" + + effective[key] = {"value": value, "source": source} + + return jsonify(effective) diff --git a/shelfmark/core/auth_modes.py b/shelfmark/core/auth_modes.py new file mode 100644 index 0000000..586ffc1 --- /dev/null +++ b/shelfmark/core/auth_modes.py @@ -0,0 +1,104 @@ +"""Authentication mode, auth-source normalization, and admin access policy helpers.""" + +import os +from typing import Any, Mapping + +AUTH_SOURCE_BUILTIN = "builtin" +AUTH_SOURCE_OIDC = "oidc" +AUTH_SOURCE_PROXY = "proxy" +AUTH_SOURCE_CWA = "cwa" +AUTH_SOURCES = ( + AUTH_SOURCE_BUILTIN, + AUTH_SOURCE_OIDC, + AUTH_SOURCE_PROXY, + AUTH_SOURCE_CWA, +) +AUTH_SOURCE_SET = frozenset(AUTH_SOURCES) + + +def has_local_password_admin(user_db: Any | None = None) -> bool: + """Return True when at least one local admin with a password exists.""" + try: + db = user_db + if db is None: + from shelfmark.core.user_db import UserDB + + config_root = os.environ.get("CONFIG_DIR", "/config") + db = UserDB(os.path.join(config_root, "users.db")) + db.initialize() + + return any( + user.get("password_hash") and user.get("role") == "admin" + for user in db.list_users() + ) + except Exception: + return False + + +def normalize_auth_source( + source: Any, + oidc_subject: Any = None, +) -> str: + """Resolve a stable auth source value from persisted fields.""" + normalized = str(source or "").strip().lower() + if normalized in AUTH_SOURCE_SET: + return normalized + if oidc_subject: + return AUTH_SOURCE_OIDC + return AUTH_SOURCE_BUILTIN + + +def determine_auth_mode( + security_config: Mapping[str, Any], + cwa_db_path: Any | None, + *, + has_local_admin: bool = True, +) -> str: + """Determine active auth mode from security config and runtime prerequisites.""" + auth_mode = security_config.get("AUTH_METHOD", "none") + + if auth_mode == AUTH_SOURCE_CWA and cwa_db_path: + return AUTH_SOURCE_CWA + + if auth_mode == AUTH_SOURCE_BUILTIN and has_local_admin: + return AUTH_SOURCE_BUILTIN + + if auth_mode == AUTH_SOURCE_PROXY and security_config.get("PROXY_AUTH_USER_HEADER"): + return AUTH_SOURCE_PROXY + + if ( + auth_mode == AUTH_SOURCE_OIDC + and has_local_admin + and security_config.get("OIDC_DISCOVERY_URL") + and security_config.get("OIDC_CLIENT_ID") + ): + return AUTH_SOURCE_OIDC + + return "none" + + +def is_settings_or_onboarding_path(path: str) -> bool: + """Return True when request path targets protected admin settings routes.""" + return path.startswith("/api/settings") or path.startswith("/api/onboarding") + + +def should_restrict_settings_to_admin( + users_config: Mapping[str, Any], +) -> bool: + """Return whether settings/onboarding access is limited to admins.""" + return bool(users_config.get("RESTRICT_SETTINGS_TO_ADMIN", True)) + + +def get_auth_check_admin_status( + _auth_mode: str, + users_config: Mapping[str, Any], + session_data: Mapping[str, Any], +) -> bool: + """Resolve /api/auth/check `is_admin` value for settings UI access control.""" + if "user_id" not in session_data: + return False + + if not should_restrict_settings_to_admin(users_config): + return True + + return bool(session_data.get("is_admin", False)) diff --git a/shelfmark/core/config.py b/shelfmark/core/config.py index 0cdf61f..387bea9 100644 --- a/shelfmark/core/config.py +++ b/shelfmark/core/config.py @@ -1,11 +1,14 @@ """Configuration singleton with ENV > config file > default resolution.""" +import os +import sqlite3 from threading import Lock from typing import Any, Dict, Optional # Import lazily to avoid circular imports _registry_module = None _env_module = None +_user_db_module = None def _get_registry(): @@ -26,6 +29,15 @@ def _get_env(): return _env_module +def _get_user_db_module(): + """Lazy import of user DB module to avoid optional dependency loops.""" + global _user_db_module + if _user_db_module is None: + from shelfmark.core.user_db import UserDB + _user_db_module = UserDB + return _user_db_module + + class Config: """ Dynamic configuration singleton that provides live settings access. @@ -51,6 +63,10 @@ class Config: self._cache: Dict[str, Any] = {} self._field_map: Dict[str, tuple] = {} # key -> (field, tab_name) self._cache_lock = Lock() + self._user_settings_cache: Dict[int, Dict[str, Any]] = {} + self._user_settings_cache_lock = Lock() + self._user_db = None + self._user_db_load_attempted = False self._initialized = True self._loaded = False @@ -111,19 +127,85 @@ class Config: with self._cache_lock: self._loaded = False self._load_settings() + with self._user_settings_cache_lock: + self._user_settings_cache.clear() + self._user_db = None + self._user_db_load_attempted = False - def get(self, key: str, default: Any = None) -> Any: + def _get_user_db(self): + """Get or initialize a UserDB handle if available.""" + if self._user_db is not None: + return self._user_db + if self._user_db_load_attempted: + return None + + self._user_db_load_attempted = True + try: + user_db_cls = _get_user_db_module() + db_path = os.path.join(os.environ.get("CONFIG_DIR", "/config"), "users.db") + user_db = user_db_cls(db_path) + user_db.initialize() + self._user_db = user_db + return self._user_db + except Exception: + # Multi-user support is optional; fall back to global config when unavailable. + return None + + def _get_user_settings(self, user_id: int) -> Dict[str, Any]: + """Get cached per-user settings from user DB.""" + with self._user_settings_cache_lock: + if user_id in self._user_settings_cache: + return self._user_settings_cache[user_id] + + user_db = self._get_user_db() + if user_db is None: + return {} + + try: + settings = user_db.get_user_settings(user_id) + except (sqlite3.OperationalError, OSError, ValueError, TypeError): + return {} + + if not isinstance(settings, dict): + settings = {} + + with self._user_settings_cache_lock: + self._user_settings_cache[user_id] = settings + return settings + + def _get_user_override(self, user_id: int, key: str) -> Any: + """Get a user override for a specific key.""" + user_settings = self._get_user_settings(user_id) + return user_settings.get(key) + + def get(self, key: str, default: Any = None, user_id: Optional[int] = None) -> Any: """ Get a setting value by key. Args: key: The setting key (e.g., 'MAX_RETRY') default: Default value if setting not found + user_id: Optional DB user ID for per-user setting overrides Returns: The setting value, or default if not found """ self._ensure_loaded() + + if key in self._field_map: + field, _ = self._field_map[key] + registry = _get_registry() + + # Deployment-level ENV values always win. + if field.env_supported and registry.is_value_from_env(field): + return self._cache.get(key, default) + + # User overrides are only available for explicitly overridable fields. + if user_id is not None and getattr(field, "user_overridable", False): + user_value = self._get_user_override(user_id, key) + if user_value is not None: + return user_value + return self._cache.get(key, default) def __getattr__(self, name: str) -> Any: diff --git a/shelfmark/core/cwa_user_sync.py b/shelfmark/core/cwa_user_sync.py new file mode 100644 index 0000000..c4fa853 --- /dev/null +++ b/shelfmark/core/cwa_user_sync.py @@ -0,0 +1,75 @@ +"""Helpers for provisioning and syncing Calibre-Web users into users.db.""" + +from __future__ import annotations + +from typing import Any, Iterable + +from shelfmark.core.external_user_linking import upsert_external_user +from shelfmark.core.user_db import UserDB + +_CWA_ALIAS_SUFFIX = "__cwa" + + +def _normalize_email(value: Any) -> str | None: + if value is None: + return None + email = str(value).strip() + return email or None + + +def upsert_cwa_user( + user_db: UserDB, + cwa_username: str, + cwa_email: str | None, + role: str, + context: str | None = None, +) -> tuple[dict[str, Any], str]: + """Create/update a CWA-backed user with collision-safe matching.""" + normalized_email = _normalize_email(cwa_email) + collision_strategy = "alias" if normalized_email else "takeover" + user, action = upsert_external_user( + user_db, + auth_source="cwa", + username=cwa_username, + email=normalized_email, + role=role, + allow_email_link=True, + collision_strategy=collision_strategy, + alias_suffix=_CWA_ALIAS_SUFFIX, + context=context, + ) + if user is None: + raise RuntimeError("Unexpected CWA user sync result: no user returned") + return user, action + + +def sync_cwa_users_from_rows( + user_db: UserDB, + rows: Iterable[tuple[Any, Any, Any]], +) -> dict[str, int]: + """Sync CWA users from raw `(name, role_flags, email)` rows.""" + created = 0 + updated = 0 + for username, role_flags, email in rows: + normalized_username = str(username or "").strip() + if not normalized_username: + continue + + role = "admin" if (int(role_flags or 0) & 1) == 1 else "user" + _, action = upsert_cwa_user( + user_db, + cwa_username=normalized_username, + cwa_email=_normalize_email(email), + role=role, + context="cwa_manual_sync", + ) + if action == "created": + created += 1 + else: + updated += 1 + + return { + "created": created, + "updated": updated, + "total": created + updated, + } diff --git a/shelfmark/core/external_user_linking.py b/shelfmark/core/external_user_linking.py new file mode 100644 index 0000000..d0d2896 --- /dev/null +++ b/shelfmark/core/external_user_linking.py @@ -0,0 +1,283 @@ +"""Shared external identity matching and provisioning helpers.""" + +from __future__ import annotations + +import re +from typing import Any, Literal + +from shelfmark.core.auth_modes import normalize_auth_source +from shelfmark.core.logger import setup_logger +from shelfmark.core.user_db import UserDB + +UNSET = object() + +CollisionStrategy = Literal["takeover", "suffix", "alias"] +MatchReason = Literal[ + "subject_match", + "existing_source_username_match", + "unique_email_match", +] + +logger = setup_logger(__name__) + + +def _normalize_username(value: Any) -> str: + return str(value or "").strip() + + +def _normalize_email(value: Any) -> str | None: + if value is None: + return None + email = str(value).strip() + return email or None + + +def _normalize_display_name(value: Any) -> str | None: + if value is None: + return None + name = str(value).strip() + return name or None + + +def _email_key(value: str | None) -> str: + return (value or "").strip().lower() + + +def _normalize_role(value: Any) -> str: + return "admin" if str(value or "").strip().lower() == "admin" else "user" + + +def _get_by_subject(user_db: UserDB, subject_field: str | None, subject: str | None) -> dict[str, Any] | None: + if not subject_field or not subject: + return None + if subject_field == "oidc_subject": + return user_db.get_user(oidc_subject=subject) + return None + + +def find_unique_user_by_email(user_db: UserDB, email: str | None) -> dict[str, Any] | None: + key = _email_key(_normalize_email(email)) + if not key: + return None + + matches = [u for u in user_db.list_users() if _email_key(u.get("email")) == key] + return matches[0] if len(matches) == 1 else None + + +def find_external_user_match( + user_db: UserDB, + *, + auth_source: str, + username: str, + email: str | None, + subject_field: str | None = None, + subject: str | None = None, + allow_email_link: bool = False, +) -> tuple[dict[str, Any] | None, MatchReason | None]: + """Find an existing local user that should be linked to an external identity.""" + normalized_username = _normalize_username(username) + normalized_email = _normalize_email(email) + + by_subject = _get_by_subject(user_db, subject_field, subject) + if by_subject is not None: + return by_subject, "subject_match" + + by_username = user_db.get_user(username=normalized_username) + if by_username and normalize_auth_source( + by_username.get("auth_source"), + by_username.get("oidc_subject"), + ) == auth_source: + return by_username, "existing_source_username_match" + + if allow_email_link: + return find_unique_user_by_email(user_db, normalized_email), "unique_email_match" + return None, None + + +def _build_updates( + *, + auth_source: str, + role: str, + sync_role: bool, + email: str | None | object, + display_name: str | None | object, + subject_field: str | None, + subject: str | None, +) -> dict[str, Any]: + updates: dict[str, Any] = {"auth_source": auth_source} + if sync_role: + updates["role"] = _normalize_role(role) + if email is not UNSET: + updates["email"] = _normalize_email(email) + if display_name is not UNSET: + updates["display_name"] = _normalize_display_name(display_name) + if subject_field == "oidc_subject" and subject: + updates["oidc_subject"] = subject + return updates + + +def _next_suffix_username(user_db: UserDB, base_username: str) -> str: + candidate = base_username + suffix = 1 + while user_db.get_user(username=candidate): + candidate = f"{base_username}_{suffix}" + suffix += 1 + return candidate + + +def _find_existing_alias_user( + user_db: UserDB, + *, + auth_source: str, + alias_base: str, +) -> dict[str, Any] | None: + pattern = re.compile(rf"^{re.escape(alias_base)}(?:_\d+)?$") + candidates = [ + user for user in user_db.list_users() + if pattern.match(str(user.get("username") or "")) + and normalize_auth_source(user.get("auth_source"), user.get("oidc_subject")) == auth_source + ] + if not candidates: + return None + return sorted(candidates, key=lambda user: int(user.get("id") or 0))[0] + + +def _resolve_create_username( + user_db: UserDB, + *, + auth_source: str, + requested_username: str, + strategy: CollisionStrategy, + alias_suffix: str, +) -> tuple[str | None, dict[str, Any] | None, str]: + existing = user_db.get_user(username=requested_username) + if not existing: + return requested_username, None, "new_username_available" + + if strategy == "takeover": + return None, existing, "username_collision_takeover" + + if strategy == "suffix": + return _next_suffix_username(user_db, requested_username), None, "username_collision_suffix" + + alias_base = f"{requested_username}{alias_suffix}" + alias_existing = _find_existing_alias_user( + user_db, + auth_source=auth_source, + alias_base=alias_base, + ) + if alias_existing is not None: + return None, alias_existing, "reuse_existing_alias" + return _next_suffix_username(user_db, alias_base), None, "username_collision_alias" + + +def upsert_external_user( + user_db: UserDB, + *, + auth_source: str, + username: str, + role: str, + email: str | None | object = UNSET, + display_name: str | None | object = UNSET, + subject_field: str | None = None, + subject: str | None = None, + allow_email_link: bool = False, + sync_role: bool = True, + allow_create: bool = True, + collision_strategy: CollisionStrategy = "takeover", + alias_suffix: str | None = None, + context: str | None = None, +) -> tuple[dict[str, Any] | None, str]: + """Create/update a user from an external auth identity. + + Returns `(user, action)` where action is one of: + - `"updated"` + - `"created"` + - `"not_found"` (when `allow_create=False` and no link target exists) + """ + normalized_username = _normalize_username(username) + if not normalized_username: + raise ValueError("External username is required") + + normalized_email = _normalize_email(email) if email is not UNSET else None + normalized_display_name = ( + _normalize_display_name(display_name) if display_name is not UNSET else None + ) + normalized_role = _normalize_role(role) + + matched, match_reason = find_external_user_match( + user_db, + auth_source=auth_source, + username=normalized_username, + email=normalized_email, + subject_field=subject_field, + subject=subject, + allow_email_link=allow_email_link, + ) + updates = _build_updates( + auth_source=auth_source, + role=normalized_role, + sync_role=sync_role, + email=normalized_email if email is not UNSET else UNSET, + display_name=normalized_display_name if display_name is not UNSET else UNSET, + subject_field=subject_field, + subject=subject, + ) + if matched is not None: + user_db.update_user(matched["id"], **updates) + mapped = user_db.get_user(user_id=matched["id"]) or matched + logger.info( + "External user mapped to existing Shelfmark user " + f"(source={auth_source}, context={context or 'unspecified'}, reason={match_reason}, " + f"external_username={normalized_username}, shelfmark_user_id={mapped['id']}, " + f"shelfmark_username={mapped['username']})" + ) + return mapped, "updated" + + if not allow_create: + logger.info( + "External user could not be mapped and creation is disabled " + f"(source={auth_source}, context={context or 'unspecified'}, " + f"external_username={normalized_username})" + ) + return None, "not_found" + + resolved_alias_suffix = alias_suffix or f"__{auth_source}" + create_username, takeover_target, create_reason = _resolve_create_username( + user_db, + auth_source=auth_source, + requested_username=normalized_username, + strategy=collision_strategy, + alias_suffix=resolved_alias_suffix, + ) + if takeover_target is not None: + user_db.update_user(takeover_target["id"], **updates) + mapped = user_db.get_user(user_id=takeover_target["id"]) or takeover_target + logger.info( + "External user mapped to existing Shelfmark user " + f"(source={auth_source}, context={context or 'unspecified'}, reason={create_reason}, " + f"external_username={normalized_username}, shelfmark_user_id={mapped['id']}, " + f"shelfmark_username={mapped['username']})" + ) + return mapped, "updated" + + create_kwargs: dict[str, Any] = { + "username": create_username, + "auth_source": auth_source, + "role": normalized_role, + } + if email is not UNSET: + create_kwargs["email"] = normalized_email + if display_name is not UNSET: + create_kwargs["display_name"] = normalized_display_name + if subject_field == "oidc_subject" and subject: + create_kwargs["oidc_subject"] = subject + + created = user_db.create_user(**create_kwargs) + logger.info( + "External user created Shelfmark user " + f"(source={auth_source}, context={context or 'unspecified'}, reason={create_reason}, " + f"external_username={normalized_username}, shelfmark_user_id={created['id']}, " + f"shelfmark_username={created['username']})" + ) + return created, "created" diff --git a/shelfmark/core/oidc_auth.py b/shelfmark/core/oidc_auth.py index 2693ab9..3f8e426 100644 --- a/shelfmark/core/oidc_auth.py +++ b/shelfmark/core/oidc_auth.py @@ -6,12 +6,9 @@ Flask route handlers are registered separately in main.py. from typing import Any, Dict, List, Optional -from shelfmark.core.logger import setup_logger +from shelfmark.core.external_user_linking import upsert_external_user from shelfmark.core.user_db import UserDB -logger = setup_logger(__name__) - - def parse_group_claims(id_token: Dict[str, Any], group_claim: str) -> List[str]: """Extract group list from an ID token claim. @@ -52,51 +49,32 @@ def provision_oidc_user( db: UserDB, user_info: Dict[str, Any], is_admin: Optional[bool] = None, -) -> Dict[str, Any]: + allow_email_link: bool = False, + allow_create: bool = True, +) -> Optional[Dict[str, Any]]: """Create or update a user from OIDC claims. - If a user with the same oidc_subject exists, updates their info. - If the username is taken by a different user, appends a numeric suffix. + Matching and collision handling use the shared external user linker: + - OIDC subject first + - optionally unique email linking (when `allow_email_link=True`) + - username conflict resolution via numeric suffix. - Admin role is synced from IdP when group-based auth is enabled (is_admin is not None): - - is_admin=True → DB role = admin - - is_admin=False → DB role = user (downgrade if needed) - - When group-based auth is disabled (is_admin=None), preserve existing DB role. - The database is always the single source of truth for auth checks. + Returns None when no existing user is matchable and `allow_create=False`. """ oidc_subject = user_info["oidc_subject"] - - # Check if user already exists by OIDC subject - existing = db.get_user(oidc_subject=oidc_subject) - if existing: - updates: Dict[str, Any] = { - "email": user_info.get("email"), - "display_name": user_info.get("display_name"), - } - # Sync role from IdP when group-based auth is enabled - if is_admin is not None: - updates["role"] = "admin" if is_admin else "user" - db.update_user(existing["id"], **updates) - return db.get_user(user_id=existing["id"]) - - role = "admin" if is_admin else "user" - - # New user — resolve username conflicts - username = user_info["username"] or oidc_subject - if db.get_user(username=username): - # Username taken, append suffix - suffix = 1 - while db.get_user(username=f"{username}_{suffix}"): - suffix += 1 - username = f"{username}_{suffix}" - - user = db.create_user( - username=username, + user, _ = upsert_external_user( + db, + auth_source="oidc", + username=user_info["username"] or oidc_subject, + role="admin" if is_admin else "user", email=user_info.get("email"), display_name=user_info.get("display_name"), - oidc_subject=oidc_subject, - role=role, + subject_field="oidc_subject", + subject=oidc_subject, + allow_email_link=allow_email_link, + sync_role=is_admin is not None, + allow_create=allow_create, + collision_strategy="suffix", + context="oidc_login", ) - logger.info(f"Provisioned OIDC user: {username} (sub={oidc_subject})") return user diff --git a/shelfmark/core/oidc_routes.py b/shelfmark/core/oidc_routes.py index da5e701..953b808 100644 --- a/shelfmark/core/oidc_routes.py +++ b/shelfmark/core/oidc_routes.py @@ -1,16 +1,13 @@ -"""OIDC Flask route handlers. +"""OIDC Flask route handlers using Authlib. Registers /api/auth/oidc/login and /api/auth/oidc/callback endpoints. -Separated from main.py to keep the OIDC logic self-contained. +Business logic remains in oidc_auth.py. """ -import hashlib -import secrets -import base64 -from urllib.parse import urlencode +from typing import Any -import requests as http_requests -from flask import Flask, redirect, request, session, jsonify +from authlib.integrations.flask_client import OAuth +from flask import Flask, jsonify, redirect, request, session from shelfmark.core.logger import setup_logger from shelfmark.core.oidc_auth import ( @@ -22,249 +19,156 @@ from shelfmark.core.settings_registry import load_config_file from shelfmark.core.user_db import UserDB logger = setup_logger(__name__) - -# Cache discovery document in memory (refreshed on restart) -_discovery_cache = {} +oauth = OAuth() -def _fetch_discovery(discovery_url: str) -> dict: - """Fetch and cache the OIDC discovery document.""" - if discovery_url in _discovery_cache: - return _discovery_cache[discovery_url] - - resp = http_requests.get(discovery_url, timeout=10) - resp.raise_for_status() - doc = resp.json() - _discovery_cache[discovery_url] = doc - return doc +def _normalize_claims(raw_claims: Any) -> dict[str, Any]: + """Return a plain dict for claims from Authlib token/userinfo payloads.""" + if raw_claims is None: + return {} + if isinstance(raw_claims, dict): + return raw_claims + if hasattr(raw_claims, "to_dict"): + return raw_claims.to_dict() # type: ignore[no-any-return] + try: + return dict(raw_claims) + except Exception: + return {} -def _generate_pkce(): - """Generate PKCE code_verifier and code_challenge.""" - code_verifier = secrets.token_urlsafe(64) - digest = hashlib.sha256(code_verifier.encode("ascii")).digest() - code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") - return code_verifier, code_challenge +def _is_email_verified(claims: dict[str, Any]) -> bool: + """Normalize provider-specific email_verified values into a strict boolean.""" + value = claims.get("email_verified", False) + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() == "true" + return False -def _exchange_code( - token_endpoint: str, - code: str, - code_verifier: str, - client_id: str, - client_secret: str, - redirect_uri: str, - userinfo_endpoint: str | None = None, -) -> dict: - """Exchange authorization code for tokens and return ID token claims. +def _get_oidc_client() -> tuple[Any, dict[str, Any]]: + """Register and return an OIDC client from the current security config.""" + config = load_config_file("security") + discovery_url = config.get("OIDC_DISCOVERY_URL", "") + client_id = config.get("OIDC_CLIENT_ID", "") - If id_token is missing from the response, falls back to calling the userinfo endpoint. - """ - resp = http_requests.post( - token_endpoint, - data={ - "grant_type": "authorization_code", - "code": code, - "code_verifier": code_verifier, - "client_id": client_id, - "client_secret": client_secret, - "redirect_uri": redirect_uri, - }, - timeout=10, - ) - resp.raise_for_status() - token_data = resp.json() + if not discovery_url or not client_id: + raise ValueError("OIDC not configured") - # Decode ID token (we trust the IdP since we just exchanged the code over TLS) - import json as json_mod - - id_token_raw = token_data.get("id_token", "") - if id_token_raw: - # Decode JWT payload without verification (already validated by TLS + code exchange) - payload = id_token_raw.split(".")[1] - # Add required Base64 padding (0-3 '=' characters) - payload += "=" * ((-len(payload)) % 4) - claims = json_mod.loads(base64.urlsafe_b64decode(payload)) + configured_scopes = config.get("OIDC_SCOPES", ["openid", "email", "profile"]) + if isinstance(configured_scopes, list): + scope_values = [str(scope).strip() for scope in configured_scopes if str(scope).strip()] + elif isinstance(configured_scopes, str): + delimiter = "," if "," in configured_scopes else " " + scope_values = [scope.strip() for scope in configured_scopes.split(delimiter) if scope.strip()] else: - # No ID token in response — try userinfo endpoint - access_token = token_data.get("access_token") - if userinfo_endpoint and access_token: - try: - logger.info("ID token not found in token response, fetching from userinfo endpoint") - userinfo_resp = http_requests.get( - userinfo_endpoint, - headers={"Authorization": f"Bearer {access_token}"}, - timeout=10, - ) - userinfo_resp.raise_for_status() - claims = userinfo_resp.json() - except http_requests.RequestException as e: - logger.error(f"Failed to fetch userinfo: {e}") - raise ValueError("OIDC authentication failed: missing id_token and userinfo endpoint unavailable") - else: - logger.error("OIDC token response missing both id_token and access_token") - raise ValueError("OIDC authentication failed: invalid token response") + scope_values = [] - return claims + scopes = list(dict.fromkeys(["openid"] + scope_values)) + + admin_group = config.get("OIDC_ADMIN_GROUP", "") + group_claim = config.get("OIDC_GROUP_CLAIM", "groups") + use_admin_group = config.get("OIDC_USE_ADMIN_GROUP", True) + if admin_group and use_admin_group and group_claim and group_claim not in scopes: + scopes.append(group_claim) + + oauth._clients.pop("shelfmark_idp", None) + oauth.register( + name="shelfmark_idp", + client_id=client_id, + client_secret=config.get("OIDC_CLIENT_SECRET", ""), + server_metadata_url=discovery_url, + client_kwargs={ + "scope": " ".join(scopes), + "code_challenge_method": "S256", + }, + overwrite=True, + ) + + client = oauth.create_client("shelfmark_idp") + if client is None: + raise RuntimeError("OIDC client initialization failed") + + return client, config def register_oidc_routes(app: Flask, user_db: UserDB) -> None: """Register OIDC authentication routes on the Flask app.""" + oauth.init_app(app) @app.route("/api/auth/oidc/login", methods=["GET"]) def oidc_login(): - """Initiate OIDC login flow. Redirects to IdP.""" + """Initiate OIDC login flow and redirect to the provider.""" try: - config = load_config_file("security") - discovery_url = config.get("OIDC_DISCOVERY_URL", "") - client_id = config.get("OIDC_CLIENT_ID", "") - - # Build scopes from config (user-editable) with openid guaranteed - configured_scopes = config.get("OIDC_SCOPES", ["openid", "email", "profile"]) - scopes = list(dict.fromkeys(["openid"] + configured_scopes)) # dedupe, openid first - admin_group = config.get("OIDC_ADMIN_GROUP", "") - group_claim = config.get("OIDC_GROUP_CLAIM", "groups") - use_admin_group = config.get("OIDC_USE_ADMIN_GROUP", True) - - # Add group claim to scopes when using admin group authorization - if admin_group and use_admin_group and group_claim and group_claim not in scopes: - scopes.append(group_claim) - - if not discovery_url or not client_id: - return jsonify({"error": "OIDC not configured"}), 500 - - discovery = _fetch_discovery(discovery_url) - auth_endpoint = discovery["authorization_endpoint"] - - # Generate PKCE and state - code_verifier, code_challenge = _generate_pkce() - state = secrets.token_urlsafe(32) - - # Store in session for callback validation - session["oidc_state"] = state - session["oidc_code_verifier"] = code_verifier - - # Build callback URL + client, _ = _get_oidc_client() redirect_uri = request.url_root.rstrip("/") + "/api/auth/oidc/callback" - - params = { - "client_id": client_id, - "response_type": "code", - "scope": " ".join(scopes), - "redirect_uri": redirect_uri, - "state": state, - "code_challenge": code_challenge, - "code_challenge_method": "S256", - } - - return redirect(f"{auth_endpoint}?{urlencode(params)}") - + return client.authorize_redirect(redirect_uri) + except ValueError: + return jsonify({"error": "OIDC not configured"}), 500 except Exception as e: logger.error(f"OIDC login error: {e}") return jsonify({"error": "OIDC login failed"}), 500 @app.route("/api/auth/oidc/callback", methods=["GET"]) def oidc_callback(): - """Handle OIDC callback from IdP.""" + """Handle OIDC callback from identity provider.""" try: - code = request.args.get("code") - state = request.args.get("state") error = request.args.get("error") - if error: logger.warning(f"OIDC callback error from IdP: {error}") return jsonify({"error": "Authentication failed"}), 400 - # Validate state - expected_state = session.get("oidc_state") - code_verifier = session.get("oidc_code_verifier") + client, config = _get_oidc_client() + token = client.authorize_access_token() + claims = _normalize_claims(token.get("userinfo")) - if not expected_state or not code_verifier: - return jsonify({"error": "Session expired. Please try logging in again."}), 400 + # If userinfo isn't present in token payload, request it explicitly. + if not claims: + try: + claims = _normalize_claims(client.userinfo(token=token)) + except TypeError: + claims = _normalize_claims(client.userinfo()) + except Exception as e: + logger.error(f"Failed to fetch OIDC userinfo: {e}") - if not state or state != expected_state: - return jsonify({"error": "Invalid state parameter"}), 400 + if not claims: + raise ValueError("OIDC authentication failed: missing user claims") - if not code: - return jsonify({"error": "Missing authorization code"}), 400 - - # Load config - config = load_config_file("security") - discovery_url = config.get("OIDC_DISCOVERY_URL", "") - client_id = config.get("OIDC_CLIENT_ID", "") - client_secret = config.get("OIDC_CLIENT_SECRET", "") group_claim = config.get("OIDC_GROUP_CLAIM", "groups") admin_group = config.get("OIDC_ADMIN_GROUP", "") use_admin_group = config.get("OIDC_USE_ADMIN_GROUP", True) auto_provision = config.get("OIDC_AUTO_PROVISION", True) - discovery = _fetch_discovery(discovery_url) - token_endpoint = discovery["token_endpoint"] - userinfo_endpoint = discovery.get("userinfo_endpoint") - redirect_uri = request.url_root.rstrip("/") + "/api/auth/oidc/callback" - - # Exchange code for tokens - claims = _exchange_code( - token_endpoint=token_endpoint, - code=code, - code_verifier=code_verifier, - client_id=client_id, - client_secret=client_secret, - redirect_uri=redirect_uri, - userinfo_endpoint=userinfo_endpoint, - ) - - # Extract user info and check groups user_info = extract_user_info(claims) groups = parse_group_claims(claims, group_claim) - # Determine admin status from group membership (if enabled) + is_admin = None if admin_group and use_admin_group: is_admin = admin_group in groups - # Check if user exists by OIDC subject first - existing_user = user_db.get_user(oidc_subject=user_info["oidc_subject"]) - - # If no match by subject, try email linking (for pre-created users) - # Only link when the IdP has verified the email to prevent privilege escalation - email_verified = claims.get("email_verified", False) - if not existing_user and user_info.get("email") and email_verified: - matching_users = [ - u for u in user_db.list_users() - if u.get("email") and u["email"].lower() == user_info["email"].lower() - ] - if len(matching_users) == 1: - existing_user = matching_users[0] - # Link OIDC subject to existing user - user_db.update_user(existing_user["id"], oidc_subject=user_info["oidc_subject"]) - logger.info(f"Linked OIDC subject {user_info['oidc_subject']} to existing user {existing_user['username']}") - elif len(matching_users) > 1: - logger.warning(f"OIDC email linking skipped: multiple local accounts match email {user_info['email']}") - - if not existing_user and not auto_provision: - logger.warning(f"OIDC login rejected: auto-provision disabled for {user_info['username']}") + allow_email_link = bool(user_info.get("email")) and _is_email_verified(claims) + user = provision_oidc_user( + user_db, + user_info, + is_admin=is_admin, + allow_email_link=allow_email_link, + allow_create=bool(auto_provision), + ) + if user is None: + logger.warning( + f"OIDC login rejected: auto-provision disabled for {user_info['username']}" + ) return jsonify({"error": "Account not found. Contact your administrator."}), 403 - # Provision or update user (database role is synced from group if enabled) - user = provision_oidc_user(user_db, user_info, is_admin=is_admin) - - # Set session - database role is the single source of truth session["user_id"] = user["username"] session["is_admin"] = user.get("role") == "admin" session["db_user_id"] = user["id"] session.permanent = True - # Clean up OIDC session data - session.pop("oidc_state", None) - session.pop("oidc_code_verifier", None) - logger.info(f"OIDC login successful: {user['username']} (admin={is_admin})") - - # Redirect to frontend (respect subpath deployments) return redirect(request.script_root or "/") except ValueError as e: - # Specific errors from _exchange_code (e.g., missing id_token and userinfo unavailable) logger.error(f"OIDC callback error: {e}") return jsonify({"error": str(e)}), 400 except Exception as e: diff --git a/shelfmark/core/settings_registry.py b/shelfmark/core/settings_registry.py index 5167ddb..2dd2376 100644 --- a/shelfmark/core/settings_registry.py +++ b/shelfmark/core/settings_registry.py @@ -22,6 +22,7 @@ class FieldBase: required: bool = False # Whether field must have a value env_var: Optional[str] = None # Override env var name (defaults to key) env_supported: bool = True # Whether this setting can be set via ENV var (False = UI-only) + user_overridable: bool = False # Whether admins can set per-user overrides for this field disabled: bool = False # Whether field is disabled/greyed out disabled_reason: str = "" # Explanation shown when disabled show_when: Optional[Dict[str, Any] | List[Dict[str, Any]]] = None # Conditional visibility: {"field": "key", "value": "expected"} or list of conditions @@ -259,6 +260,42 @@ def get_all_settings_tabs() -> List[SettingsTab]: return sorted(_SETTINGS_REGISTRY.values(), key=lambda t: (t.order, t.name)) +def _iter_value_fields(tab: SettingsTab): + """Yield value-bearing fields for a tab.""" + for field in tab.fields: + if isinstance(field, (ActionButton, HeadingField)): + continue + yield field + + +def get_settings_field_map(tab_name: Optional[str] = None) -> Dict[str, tuple[SettingsField, str]]: + """Return key -> (field, tab_name) map for value-bearing settings fields.""" + tabs: List[SettingsTab] + if tab_name: + tab = get_settings_tab(tab_name) + if not tab: + return {} + tabs = [tab] + else: + tabs = get_all_settings_tabs() + + field_map: Dict[str, tuple[SettingsField, str]] = {} + for tab in tabs: + for field in _iter_value_fields(tab): + field_map[field.key] = (field, tab.name) + return field_map + + +def get_user_overridable_fields(tab_name: Optional[str] = None) -> Dict[str, tuple[SettingsField, str]]: + """Return key -> (field, tab_name) map for fields marked user_overridable.""" + field_map = get_settings_field_map(tab_name=tab_name) + return { + key: (field, tab) + for key, (field, tab) in field_map.items() + if getattr(field, "user_overridable", False) + } + + def list_registered_settings() -> List[str]: """List all registered settings tab names.""" return list(_SETTINGS_REGISTRY.keys()) @@ -685,6 +722,7 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T "disabled": getattr(field, 'disabled', False), "disabledReason": getattr(field, 'disabled_reason', ''), "requiresRestart": getattr(field, 'requires_restart', False), + "userOverridable": getattr(field, 'user_overridable', False), } # Add optional properties if set diff --git a/shelfmark/core/user_db.py b/shelfmark/core/user_db.py index a318a59..dd6cd30 100644 --- a/shelfmark/core/user_db.py +++ b/shelfmark/core/user_db.py @@ -1,10 +1,12 @@ """SQLite user database for multi-user support.""" import json +import os import sqlite3 import threading from typing import Any, Dict, List, Optional +from shelfmark.core.auth_modes import AUTH_SOURCE_BUILTIN, AUTH_SOURCE_SET from shelfmark.core.logger import setup_logger logger = setup_logger(__name__) @@ -17,6 +19,7 @@ CREATE TABLE IF NOT EXISTS users ( display_name TEXT, password_hash TEXT, oidc_subject TEXT UNIQUE, + auth_source TEXT NOT NULL DEFAULT 'builtin', role TEXT NOT NULL DEFAULT 'user', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); @@ -28,9 +31,54 @@ CREATE TABLE IF NOT EXISTS user_settings ( """ +def get_users_db_path(config_dir: Optional[str] = None) -> str: + """Return the configured users database path.""" + root = config_dir or os.environ.get("CONFIG_DIR", "/config") + return os.path.join(root, "users.db") + + +def sync_builtin_admin_user( + username: str, + password_hash: str, + db_path: Optional[str] = None, +) -> None: + """Ensure a local admin user exists for configured builtin credentials.""" + normalized_username = (username or "").strip() + normalized_hash = password_hash or "" + if not normalized_username or not normalized_hash: + return + + user_db = UserDB(db_path or get_users_db_path()) + user_db.initialize() + + existing = user_db.get_user(username=normalized_username) + if existing: + updates: dict[str, Any] = {} + if existing.get("password_hash") != normalized_hash: + updates["password_hash"] = normalized_hash + if existing.get("role") != "admin": + updates["role"] = "admin" + if existing.get("auth_source") != AUTH_SOURCE_BUILTIN: + updates["auth_source"] = AUTH_SOURCE_BUILTIN + if updates: + user_db.update_user(existing["id"], **updates) + logger.info(f"Updated local admin user '{normalized_username}' from builtin settings") + return + + user_db.create_user( + username=normalized_username, + password_hash=normalized_hash, + auth_source=AUTH_SOURCE_BUILTIN, + role="admin", + ) + logger.info(f"Created local admin user '{normalized_username}' from builtin settings") + + class UserDB: """Thread-safe SQLite user database.""" + _VALID_AUTH_SOURCES = set(AUTH_SOURCE_SET) + def __init__(self, db_path: str): self._db_path = db_path self._lock = threading.Lock() @@ -47,12 +95,33 @@ class UserDB: conn = self._connect() try: conn.executescript(_CREATE_TABLES_SQL) - conn.execute("PRAGMA journal_mode=WAL") + self._migrate_auth_source_column(conn) conn.commit() + # WAL mode must be changed outside an open transaction. + conn.execute("PRAGMA journal_mode=WAL") finally: conn.close() logger.info(f"User database initialized at {self._db_path}") + def _migrate_auth_source_column(self, conn: sqlite3.Connection) -> None: + """Ensure users.auth_source exists and backfill historical rows.""" + columns = conn.execute("PRAGMA table_info(users)").fetchall() + column_names = {str(col["name"]) for col in columns} + + if "auth_source" not in column_names: + conn.execute( + "ALTER TABLE users ADD COLUMN auth_source TEXT NOT NULL DEFAULT 'builtin'" + ) + + # Backfill OIDC-origin users created before auth_source existed. + conn.execute( + "UPDATE users SET auth_source = 'oidc' WHERE oidc_subject IS NOT NULL" + ) + # Defensive cleanup for any legacy null/blank values. + conn.execute( + "UPDATE users SET auth_source = 'builtin' WHERE auth_source IS NULL OR auth_source = ''" + ) + def create_user( self, username: str, @@ -60,16 +129,29 @@ class UserDB: display_name: Optional[str] = None, password_hash: Optional[str] = None, oidc_subject: Optional[str] = None, + auth_source: str = "builtin", role: str = "user", ) -> Dict[str, Any]: """Create a new user. Raises ValueError if username or oidc_subject already exists.""" + if auth_source not in self._VALID_AUTH_SOURCES: + raise ValueError(f"Invalid auth_source: {auth_source}") with self._lock: conn = self._connect() try: cursor = conn.execute( - """INSERT INTO users (username, email, display_name, password_hash, oidc_subject, role) - VALUES (?, ?, ?, ?, ?, ?)""", - (username, email, display_name, password_hash, oidc_subject, role), + """INSERT INTO users ( + username, email, display_name, password_hash, oidc_subject, auth_source, role + ) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + ( + username, + email, + display_name, + password_hash, + oidc_subject, + auth_source, + role, + ), ) conn.commit() user_id = cursor.lastrowid @@ -108,7 +190,14 @@ class UserDB: row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() return dict(row) if row else None - _ALLOWED_UPDATE_COLUMNS = {"email", "display_name", "password_hash", "oidc_subject", "role"} + _ALLOWED_UPDATE_COLUMNS = { + "email", + "display_name", + "password_hash", + "oidc_subject", + "auth_source", + "role", + } def update_user(self, user_id: int, **kwargs) -> None: """Update user fields. Raises ValueError if user not found or invalid column.""" @@ -117,6 +206,8 @@ class UserDB: for k in kwargs: if k not in self._ALLOWED_UPDATE_COLUMNS: raise ValueError(f"Invalid column: {k}") + if "auth_source" in kwargs and kwargs["auth_source"] not in self._VALID_AUTH_SOURCES: + raise ValueError(f"Invalid auth_source: {kwargs['auth_source']}") with self._lock: conn = self._connect() try: diff --git a/shelfmark/core/utils.py b/shelfmark/core/utils.py index 210345f..daf4e71 100644 --- a/shelfmark/core/utils.py +++ b/shelfmark/core/utils.py @@ -1,6 +1,8 @@ """Shared utility functions for the Shelfmark.""" import base64 +import os +import re from pathlib import Path from typing import Optional from urllib.parse import urlparse @@ -118,21 +120,88 @@ _LEGACY_CONTENT_TYPE_TO_CONFIG_KEY = { "other": "INGEST_DIR_OTHER", } +_USER_PLACEHOLDER_PATTERN = re.compile(r"\{user\}", re.IGNORECASE) +_INVALID_USER_PATH_CHARS = re.compile(r'[\\/:*?"<>|]') -def get_destination(is_audiobook: bool = False) -> Path: + +def _sanitize_user_for_path(username: str) -> str: + """Sanitize username for path usage in destination placeholders.""" + sanitized = _INVALID_USER_PATH_CHARS.sub("_", username.strip()) + return sanitized.strip(" .") + + +def _resolve_destination_username( + user_id: Optional[int] = None, + username: Optional[str] = None, +) -> str: + explicit = str(username or "").strip() + if explicit: + return explicit + + if user_id is None: + return "" + + try: + from shelfmark.core.user_db import UserDB + + user_db = UserDB(os.path.join(os.environ.get("CONFIG_DIR", "/config"), "users.db")) + user_db.initialize() + user = user_db.get_user(user_id=user_id) + if not user: + return "" + return str(user.get("username") or "").strip() + except Exception: + return "" + + +def _expand_user_destination_placeholder( + path_value: str, + user_id: Optional[int] = None, + username: Optional[str] = None, +) -> str: + """Expand `{User}` placeholders in destination paths.""" + if not isinstance(path_value, str): + return path_value + + if not _USER_PLACEHOLDER_PATTERN.search(path_value): + return path_value + + resolved_username = _sanitize_user_for_path( + _resolve_destination_username(user_id=user_id, username=username) + ) + return _USER_PLACEHOLDER_PATTERN.sub(resolved_username, path_value) + + +def get_destination( + is_audiobook: bool = False, + user_id: Optional[int] = None, + username: Optional[str] = None, +) -> Path: """Get base destination directory. Audiobooks fall back to main destination.""" from shelfmark.core.config import config if is_audiobook: # Audiobook destination with fallback to main destination - audiobook_dest = config.get("DESTINATION_AUDIOBOOK", "") + audiobook_dest = config.get("DESTINATION_AUDIOBOOK", "", user_id=user_id) if audiobook_dest: - return Path(audiobook_dest) + return Path( + _expand_user_destination_placeholder( + str(audiobook_dest), + user_id=user_id, + username=username, + ) + ) # Main destination (also fallback for audiobooks) # Check new setting first, then legacy INGEST_DIR - destination = config.get("DESTINATION", "") or config.get("INGEST_DIR", "/books") - return Path(destination) + destination = config.get("DESTINATION", "", user_id=user_id) or config.get("INGEST_DIR", "/books") + return Path( + _expand_user_destination_placeholder( + str(destination), + user_id=user_id, + username=username, + ) + ) def get_aa_content_type_dir(content_type: Optional[str] = None) -> Optional[Path]: diff --git a/shelfmark/download/orchestrator.py b/shelfmark/download/orchestrator.py index d1ec3d1..c19710a 100644 --- a/shelfmark/download/orchestrator.py +++ b/shelfmark/download/orchestrator.py @@ -9,6 +9,7 @@ import random import threading import time from concurrent.futures import Future, ThreadPoolExecutor +from email.utils import parseaddr from pathlib import Path from threading import Event, Lock from typing import Any, Dict, List, Optional, Tuple @@ -75,63 +76,34 @@ def get_book_info(book_id: str) -> Optional[Dict[str, Any]]: logger.error_trace(f"Error getting book info: {e}") raise -def _normalize_email_recipients(value: Any) -> List[Dict[str, str]]: - """Normalize EMAIL_RECIPIENTS config into a list of {nickname,email} dicts.""" - - if not isinstance(value, list): - return [] - - recipients: List[Dict[str, str]] = [] - for entry in value: - if not isinstance(entry, dict): - continue - nickname = str(entry.get("nickname", "") or "").strip() - email = str(entry.get("email", "") or "").strip() - if not nickname or not email: - continue - recipients.append({"nickname": nickname, "email": email}) - return recipients +def _is_plain_email_address(value: str) -> bool: + parsed = parseaddr(value or "")[1] + return bool(parsed) and "@" in parsed and parsed == value -def _resolve_email_recipient( - nickname: Optional[str], - user_recipients: Optional[List[Dict[str, str]]] = None, -) -> Tuple[Optional[str], Optional[str], Optional[str]]: - """Resolve a configured email recipient nickname to an email address. - - Checks user-specific recipients first, then global config. +def _resolve_email_destination( + user_id: Optional[int] = None, +) -> Tuple[Optional[str], Optional[str]]: + """Resolve the destination email address for email output mode. Returns: - (email_to, label, error_message) + (email_to, error_message) """ + configured_recipient = str(config.get("EMAIL_RECIPIENT", "", user_id=user_id) or "").strip() + if configured_recipient: + if _is_plain_email_address(configured_recipient): + return configured_recipient, None + return None, "Configured email recipient is invalid" - label = (nickname or "").strip() - if not label: - return None, None, None - - # Check per-user recipients first - if user_recipients: - for entry in _normalize_email_recipients(user_recipients): - if entry["nickname"].strip().lower() == label.lower(): - return entry["email"], entry["nickname"], None - - # Fall back to global recipients - recipients = _normalize_email_recipients(config.get("EMAIL_RECIPIENTS", [])) - for entry in recipients: - if entry["nickname"].strip().lower() == label.lower(): - return entry["email"], entry["nickname"], None - - return None, label, f"Unknown email recipient: {label}" + return None, "Email recipient is required" def queue_book( book_id: str, priority: int = 0, source: str = "direct_download", - email_recipient: Optional[str] = None, user_id: Optional[int] = None, username: Optional[str] = None, - user_overrides: Optional[Dict[str, Any]] = None, ) -> Tuple[bool, Optional[str]]: """Add a book to the download queue. Returns (success, error_message).""" try: @@ -141,37 +113,23 @@ def queue_book( logger.warning(error_msg) return False, error_msg - books_output_mode = str(config.get("BOOKS_OUTPUT_MODE", "folder") or "folder").strip().lower() + books_output_mode = str( + config.get("BOOKS_OUTPUT_MODE", "folder", user_id=user_id) or "folder" + ).strip().lower() is_audiobook = check_audiobook(book_info.content) # Capture output mode at queue time so tasks aren't affected if settings change later. output_mode = "folder" if is_audiobook else books_output_mode output_args: Dict[str, Any] = {} - # Extract per-user email recipients for resolution (if any) - _user_email_recipients = (user_overrides or {}).get("email_recipients") if user_overrides else None - if output_mode == "email" and not is_audiobook: - all_recipients = _user_email_recipients or config.get("EMAIL_RECIPIENTS", []) - if not _normalize_email_recipients(all_recipients): - return False, "No email recipients configured" - - email_to, email_label, email_error = _resolve_email_recipient( - email_recipient, user_recipients=_user_email_recipients, - ) + email_to, email_error = _resolve_email_destination(user_id=user_id) if email_error: return False, email_error if not email_to: return False, "Email recipient is required" - output_args = {"to": email_to, "label": email_label} - - # Merge per-user overrides into output_args (only known keys) - _ALLOWED_OVERRIDE_KEYS = {"destination", "booklore_library_id", "booklore_path_id"} - if user_overrides: - for k, v in user_overrides.items(): - if k in _ALLOWED_OVERRIDE_KEYS and v is not None and k not in output_args: - output_args[k] = v + output_args = {"to": email_to} # Create a source-agnostic download task task = DownloadTask( @@ -215,10 +173,8 @@ def queue_book( def queue_release( release_data: dict, priority: int = 0, - email_recipient: Optional[str] = None, user_id: Optional[int] = None, username: Optional[str] = None, - user_overrides: Optional[Dict[str, Any]] = None, ) -> Tuple[bool, Optional[str]]: """Add a release to the download queue. Returns (success, error_message).""" try: @@ -236,36 +192,22 @@ def queue_release( series_position = release_data.get('series_position') or extra.get('series_position') subtitle = release_data.get('subtitle') or extra.get('subtitle') - books_output_mode = str(config.get("BOOKS_OUTPUT_MODE", "folder") or "folder").strip().lower() + books_output_mode = str( + config.get("BOOKS_OUTPUT_MODE", "folder", user_id=user_id) or "folder" + ).strip().lower() is_audiobook = check_audiobook(content_type) output_mode = "folder" if is_audiobook else books_output_mode output_args: Dict[str, Any] = {} - # Extract per-user email recipients for resolution (if any) - _user_email_recipients = (user_overrides or {}).get("email_recipients") if user_overrides else None - if output_mode == "email" and not is_audiobook: - all_recipients = _user_email_recipients or config.get("EMAIL_RECIPIENTS", []) - if not _normalize_email_recipients(all_recipients): - return False, "No email recipients configured" - - email_to, email_label, email_error = _resolve_email_recipient( - email_recipient, user_recipients=_user_email_recipients, - ) + email_to, email_error = _resolve_email_destination(user_id=user_id) if email_error: return False, email_error if not email_to: return False, "Email recipient is required" - output_args = {"to": email_to, "label": email_label} - - # Merge per-user overrides into output_args (only known keys) - _ALLOWED_OVERRIDE_KEYS = {"destination", "booklore_library_id", "booklore_path_id"} - if user_overrides: - for k, v in user_overrides.items(): - if k in _ALLOWED_OVERRIDE_KEYS and v is not None and k not in output_args: - output_args[k] = v + output_args = {"to": email_to} # Create a source-agnostic download task from release data task = DownloadTask( diff --git a/shelfmark/download/outputs/booklore.py b/shelfmark/download/outputs/booklore.py index bb65fb2..03ed8b3 100644 --- a/shelfmark/download/outputs/booklore.py +++ b/shelfmark/download/outputs/booklore.py @@ -50,7 +50,7 @@ def _parse_int(value: Any, label: str) -> int: def build_booklore_config( values: Mapping[str, Any], - user_overrides: Optional[Dict[str, Any]] = None, + user_id: Optional[int] = None, ) -> BookloreConfig: base_url = str(values.get("BOOKLORE_HOST", "")).strip() username = str(values.get("BOOKLORE_USERNAME", "")).strip() @@ -63,12 +63,21 @@ def build_booklore_config( if not password: raise BookloreError("Booklore password is required") - # Per-user library/path overrides (auth stays global) - overrides = user_overrides or {} - _lib_override = overrides.get("booklore_library_id") - library_id_val = _lib_override if _lib_override is not None else values.get("BOOKLORE_LIBRARY_ID") - _path_override = overrides.get("booklore_path_id") - path_id_val = _path_override if _path_override is not None else values.get("BOOKLORE_PATH_ID") + # Resolve library/path through config so user override precedence is centralized. + if user_id is not None: + library_id_val = core_config.config.get( + "BOOKLORE_LIBRARY_ID", + values.get("BOOKLORE_LIBRARY_ID"), + user_id=user_id, + ) + path_id_val = core_config.config.get( + "BOOKLORE_PATH_ID", + values.get("BOOKLORE_PATH_ID"), + user_id=user_id, + ) + else: + library_id_val = values.get("BOOKLORE_LIBRARY_ID") + path_id_val = values.get("BOOKLORE_PATH_ID") library_id = _parse_int(library_id_val, "Booklore library ID") path_id = _parse_int(path_id_val, "Booklore path ID") @@ -221,7 +230,7 @@ def _post_process_booklore( try: booklore_config = build_booklore_config( _get_booklore_settings(), - user_overrides=task.output_args if task.output_args else None, + user_id=task.user_id, ) except BookloreError as e: logger.warning("Task %s: Booklore configuration error: %s", task.task_id, e) diff --git a/shelfmark/download/postprocess/destination.py b/shelfmark/download/postprocess/destination.py index 85b139e..df1ccc2 100644 --- a/shelfmark/download/postprocess/destination.py +++ b/shelfmark/download/postprocess/destination.py @@ -59,12 +59,7 @@ def validate_destination(destination: Path, status_callback) -> bool: def get_final_destination(task: DownloadTask) -> Path: - """Get final destination directory, with content-type routing and per-user override support.""" - - # Per-user destination override (set by admin in user settings) - user_dest = task.output_args.get("destination", "") - if user_dest: - return Path(user_dest) + """Get final destination directory, with content-type routing support.""" is_audiobook = check_audiobook(task.content_type) @@ -73,4 +68,4 @@ def get_final_destination(task: DownloadTask) -> Path: if override: return override - return get_destination(is_audiobook) + return get_destination(is_audiobook, user_id=task.user_id, username=task.username) diff --git a/shelfmark/main.py b/shelfmark/main.py index e6ad869..6f9a798 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -27,6 +27,15 @@ from shelfmark.core.config import config as app_config from shelfmark.core.logger import setup_logger from shelfmark.core.models import SearchFilters from shelfmark.core.prefix_middleware import PrefixMiddleware +from shelfmark.core.auth_modes import ( + determine_auth_mode, + get_auth_check_admin_status, + has_local_password_admin, + is_settings_or_onboarding_path, + should_restrict_settings_to_admin, +) +from shelfmark.core.cwa_user_sync import upsert_cwa_user +from shelfmark.core.external_user_linking import upsert_external_user from shelfmark.core.utils import normalize_base_path from shelfmark.api.websocket import ws_manager @@ -181,28 +190,20 @@ def get_client_ip() -> str: def get_auth_mode() -> str: """Determine which authentication mode is active. - Priority: - 1. CWA (if enabled in settings and DB path exists) - 2. Built-in credentials (if configured) - 3. No auth required or error -> "none" + Uses configured AUTH_METHOD plus runtime prerequisites. + Returns "none" when config is invalid or unavailable. """ from shelfmark.core.settings_registry import load_config_file try: security_config = load_config_file("security") - auth_mode = security_config.get("AUTH_METHOD", "none") - if auth_mode == "cwa" and CWA_DB_PATH: - return "cwa" - if auth_mode == "builtin" and security_config.get("BUILTIN_USERNAME") and security_config.get("BUILTIN_PASSWORD_HASH"): - return "builtin" - if auth_mode == "proxy" and security_config.get("PROXY_AUTH_USER_HEADER"): - return "proxy" - if auth_mode == "oidc" and security_config.get("OIDC_DISCOVERY_URL") and security_config.get("OIDC_CLIENT_ID"): - return "oidc" + return determine_auth_mode( + security_config, + CWA_DB_PATH, + has_local_admin=has_local_password_admin(user_db), + ) except Exception: - pass - - return "none" + return "none" # Enable CORS in development mode for local frontend development @@ -327,26 +328,51 @@ def proxy_auth_middleware(): logger.warning(f"Proxy auth enabled but no username found in header '{user_header}'") return jsonify({"error": "Authentication required. Proxy header not set."}), 401 - # Check if settings access should be restricted to admins - restrict_to_admin = security_config.get("PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN", False) - is_admin = True # Default to admin if not restricting - - if restrict_to_admin: - admin_group_header = security_config.get("PROXY_AUTH_ADMIN_GROUP_HEADER", "X-Auth-Groups") - admin_group_name = security_config.get("PROXY_AUTH_ADMIN_GROUP_NAME", "admins") - - # Extract groups from proxy header (can be comma or pipe separated) + # Resolve admin role for proxy sessions. + # If an admin group is configured, derive from groups header. + # Otherwise preserve existing DB role for known users and default + # first-time users to admin (to avoid lockouts). + admin_group_header = security_config.get("PROXY_AUTH_ADMIN_GROUP_HEADER", "X-Auth-Groups") + admin_group_name = str(security_config.get("PROXY_AUTH_ADMIN_GROUP_NAME", "") or "").strip() + is_admin = True + + if admin_group_name: groups_header = get_proxy_header(admin_group_header) or "" user_groups_delimiter = "," if "," in groups_header else "|" user_groups = [g.strip() for g in groups_header.split(user_groups_delimiter) if g.strip()] - is_admin = admin_group_name in user_groups + elif user_db is not None: + existing_db_user = user_db.get_user(username=username) + if existing_db_user: + is_admin = existing_db_user.get("role") == "admin" # Create or update session + previous_username = session.get('user_id') + if previous_username and previous_username != username: + # Header identity changed mid-session; force reprovision for the new user. + session.pop('db_user_id', None) + session['user_id'] = username session['is_admin'] = is_admin + + # Provision proxy-authenticated users into users.db for multi-user features. + if user_db is not None and 'db_user_id' not in session: + role = "admin" if is_admin else "user" + db_user, _ = upsert_external_user( + user_db, + auth_source="proxy", + username=username, + role=role, + collision_strategy="takeover", + context="proxy_request", + ) + if db_user is None: + raise RuntimeError("Unexpected proxy user sync result: no user returned") + + session['db_user_id'] = db_user["id"] + session.permanent = False - + return None except Exception as e: @@ -371,22 +397,13 @@ def login_required(f): if 'user_id' not in session: return jsonify({"error": "Unauthorized"}), 401 - # Check admin access for settings endpoints (proxy, CWA, OIDC, and builtin modes) - if auth_mode in ("proxy", "cwa", "oidc", "builtin") and (request.path.startswith('/api/settings') or request.path.startswith('/api/onboarding')): + # Check admin access for settings/onboarding endpoints. + if is_settings_or_onboarding_path(request.path): from shelfmark.core.settings_registry import load_config_file try: - security_config = load_config_file("security") - - if auth_mode == "builtin": - # Builtin multi-user: settings are always admin-only - restrict_to_admin = 'db_user_id' in session - elif auth_mode == "proxy": - restrict_to_admin = security_config.get("PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN", False) - else: - # For OIDC and CWA, settings are always admin-only - # The user's admin status comes from their group or database role - restrict_to_admin = True + users_config = load_config_file("users") + restrict_to_admin = should_restrict_settings_to_admin(users_config) if restrict_to_admin and not session.get('is_admin', False): return jsonify({"error": "Admin access required"}), 403 @@ -592,14 +609,12 @@ def api_download() -> Union[Response, Tuple[Response, int]]: try: priority = int(request.args.get('priority', 0)) - email_recipient = request.args.get('email_recipient') # Per-user download overrides db_user_id = session.get('db_user_id') _username = session.get('user_id') - _user_overrides = user_db.get_user_settings(db_user_id) if (user_db and db_user_id) else {} success, error_msg = backend.queue_book( - book_id, priority, email_recipient=email_recipient, - user_id=db_user_id, username=_username, user_overrides=_user_overrides, + book_id, priority, + user_id=db_user_id, username=_username, ) if success: return jsonify({"status": "queued", "priority": priority}) @@ -638,14 +653,12 @@ def api_download_release() -> Union[Response, Tuple[Response, int]]: return jsonify({"error": "source_id is required"}), 400 priority = data.get('priority', 0) - email_recipient = data.get('email_recipient') # Per-user download overrides db_user_id = session.get('db_user_id') _username = session.get('user_id') - _user_overrides = user_db.get_user_settings(db_user_id) if (user_db and db_user_id) else {} success, error_msg = backend.queue_release( - data, priority, email_recipient=email_recipient, - user_id=db_user_id, username=_username, user_overrides=_user_overrides, + data, priority, + user_id=db_user_id, username=_username, ) if success: @@ -674,22 +687,6 @@ def api_config() -> Union[Response, Tuple[Response, int]]: from shelfmark.config.env import _is_config_dir_writable from shelfmark.core.onboarding import is_onboarding_complete as _get_onboarding_complete - def _normalize_email_recipients(value: Any) -> list[dict[str, str]]: - if not isinstance(value, list): - return [] - - recipients: list[dict[str, str]] = [] - for entry in value: - if not isinstance(entry, dict): - continue - nickname = str(entry.get("nickname", "") or "").strip() - email = str(entry.get("email", "") or "").strip() - if not nickname or not email: - continue - recipients.append({"nickname": nickname, "email": email}) - - return recipients - config = { "calibre_web_url": app_config.get("CALIBRE_WEB_URL", ""), "audiobook_library_url": app_config.get("AUDIOBOOK_LIBRARY_URL", ""), @@ -705,9 +702,6 @@ def api_config() -> Union[Response, Tuple[Response, int]]: "metadata_search_fields": get_provider_search_fields(), "default_release_source": app_config.get("DEFAULT_RELEASE_SOURCE", "direct_download"), "books_output_mode": app_config.get("BOOKS_OUTPUT_MODE", "folder"), - # Safe-to-expose subset of email output settings (recipients only). - # SMTP credentials are configured via the settings UI but are never returned to the frontend. - "email_recipients": _normalize_email_recipients(app_config.get("EMAIL_RECIPIENTS", []) or []), "auto_open_downloads_sidebar": app_config.get("AUTO_OPEN_DOWNLOADS_SIDEBAR", True), "download_to_browser": app_config.get("DOWNLOAD_TO_BROWSER", False), "settings_enabled": _is_config_dir_writable(), @@ -1066,8 +1060,6 @@ def api_login() -> Union[Response, Tuple[Response, int]]: Returns: flask.Response: JSON with success status or error message. """ - from shelfmark.core.settings_registry import load_config_file - try: ip_address = get_client_ip() data = request.get_json() @@ -1111,22 +1103,8 @@ def api_login() -> Union[Response, Tuple[Response, int]]: try: db_user = user_db.get_user(username=username) - # If user not in DB, try legacy config credentials and auto-migrate if not db_user: - security_config = load_config_file("security") - stored_username = security_config.get("BUILTIN_USERNAME", "") - stored_hash = security_config.get("BUILTIN_PASSWORD_HASH", "") - - if username == stored_username and stored_hash and check_password_hash(stored_hash, password): - # Auto-migrate: create admin user in DB from config - db_user = user_db.create_user( - username=stored_username, - password_hash=stored_hash, - role="admin", - ) - logger.info(f"Migrated builtin admin '{stored_username}' to users database") - else: - return _failed_login_response(username, ip_address) + return _failed_login_response(username, ip_address) # Authenticate against DB user if db_user: @@ -1160,7 +1138,7 @@ def api_login() -> Union[Response, Tuple[Response, int]]: db_uri = f"file:{db_path}?mode=ro&immutable=1" conn = sqlite3.connect(db_uri, uri=True) cur = conn.cursor() - cur.execute("SELECT password, role FROM user WHERE name = ?", (username,)) + cur.execute("SELECT password, role, email FROM user WHERE name = ?", (username,)) row = cur.fetchone() conn.close() @@ -1171,10 +1149,25 @@ def api_login() -> Union[Response, Tuple[Response, int]]: # Check if user has admin role (ROLE_ADMIN = 1, bit flag) user_role = row[1] if row[1] is not None else 0 is_admin = (user_role & 1) == 1 + cwa_email = row[2] or None + + db_user_id = None + if user_db is not None: + role = "admin" if is_admin else "user" + db_user, _ = upsert_cwa_user( + user_db, + cwa_username=username, + cwa_email=cwa_email, + role=role, + context="cwa_login", + ) + db_user_id = db_user["id"] # Successful authentication - create session and clear failed attempts session['user_id'] = username session['is_admin'] = is_admin + if db_user_id is not None: + session['db_user_id'] = db_user_id session.permanent = remember_me clear_failed_logins(username) logger.info(f"Login successful for user '{username}' from IP {ip_address} (CWA auth, is_admin={is_admin}, remember_me={remember_me})") @@ -1234,6 +1227,7 @@ def api_auth_check() -> Union[Response, Tuple[Response, int]]: try: security_config = load_config_file("security") + users_config = load_config_file("users") auth_mode = get_auth_mode() # If no authentication is configured, access is allowed (full admin) @@ -1248,35 +1242,28 @@ def api_auth_check() -> Union[Response, Tuple[Response, int]]: # Check if user has a valid session is_authenticated = 'user_id' in session - # Determine admin status for settings access - # - Built-in auth: check DB user role (legacy single-user is always admin) - # - CWA auth: check RESTRICT_SETTINGS_TO_ADMIN setting - # - Proxy auth: check PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN setting - if auth_mode == "builtin": - is_admin = session.get('is_admin', True) - elif auth_mode == "cwa": - restrict_to_admin = security_config.get("CWA_RESTRICT_SETTINGS_TO_ADMIN", False) - if restrict_to_admin: - is_admin = session.get('is_admin', False) - else: - # All authenticated CWA users can access settings - is_admin = True - elif auth_mode == "proxy": - restrict_to_admin = security_config.get("PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN", False) - is_admin = session.get('is_admin', not restrict_to_admin) - elif auth_mode == "oidc": - # OIDC admin status is determined by group membership during login - # and stored in session['is_admin'] - use it directly - is_admin = session.get('is_admin', False) - else: - is_admin = False + is_admin = get_auth_check_admin_status(auth_mode, users_config, session) + + display_name = None + if is_authenticated and session.get('db_user_id'): + try: + from shelfmark.core.user_db import UserDB + import os + user_db = UserDB(os.path.join(os.environ.get("CONFIG_DIR", "/config"), "users.db")) + user_db.initialize() + db_user = user_db.get_user(user_id=session['db_user_id']) + if db_user: + display_name = db_user.get("display_name") or None + except Exception: + pass response_data = { "authenticated": is_authenticated, "auth_required": True, "auth_mode": auth_mode, "is_admin": is_admin if is_authenticated else False, - "username": session.get('user_id') if is_authenticated else None + "username": session.get('user_id') if is_authenticated else None, + "display_name": display_name, } # Add logout URL for proxy auth if configured @@ -1284,6 +1271,12 @@ def api_auth_check() -> Union[Response, Tuple[Response, int]]: logout_url = security_config.get("PROXY_AUTH_LOGOUT_URL", "") if logout_url: response_data["logout_url"] = logout_url + + # Add custom OIDC button label if configured + if auth_mode == "oidc": + oidc_button_label = security_config.get("OIDC_BUTTON_LABEL", "") + if oidc_button_label: + response_data["oidc_button_label"] = oidc_button_label return jsonify(response_data) except Exception as e: diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 32eba11..b9fccd4 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -19,7 +19,6 @@ import { SearchSection } from './components/SearchSection'; import { AdvancedFilters } from './components/AdvancedFilters'; import { ResultsSection } from './components/ResultsSection'; import { DetailsModal } from './components/DetailsModal'; -import { EmailRecipientModal } from './components/EmailRecipientModal'; import { ReleaseModal } from './components/ReleaseModal'; import { DownloadsSidebar } from './components/DownloadsSidebar'; import { ToastContainer } from './components/ToastContainer'; @@ -30,7 +29,6 @@ import { ConfigSetupBanner } from './components/ConfigSetupBanner'; import { OnboardingModal } from './components/OnboardingModal'; import { DEFAULT_LANGUAGES, DEFAULT_SUPPORTED_FORMATS } from './data/languages'; import { buildSearchQuery } from './utils/buildSearchQuery'; -import { UserCancelledError, isUserCancelledError } from './utils/errors'; import { withBasePath } from './utils/basePath'; import { SearchModeProvider } from './contexts/SearchModeContext'; import './styles.css'; @@ -80,6 +78,9 @@ function App() { authChecked, isAdmin, authMode, + username, + displayName, + oidcButtonLabel, loginError, isLoggingIn, setIsAuthenticated, @@ -145,49 +146,6 @@ function App() { const [settingsOpen, setSettingsOpen] = useState(false); const [configBannerOpen, setConfigBannerOpen] = useState(false); const [onboardingOpen, setOnboardingOpen] = useState(false); - const [emailRecipientModalOpen, setEmailRecipientModalOpen] = useState(false); - const emailRecipientPromiseRef = useRef<{ - resolve: (nickname: string) => void; - reject: (error: Error) => void; - } | null>(null); - - const openEmailRecipientPicker = useCallback(async (): Promise => { - const recipients = config?.email_recipients ?? []; - if (!config || config.books_output_mode !== 'email') { - return ''; - } - - if (recipients.length === 0) { - throw new Error('No email recipients configured'); - } - - if (recipients.length === 1) { - return recipients[0]?.nickname ?? ''; - } - - if (emailRecipientPromiseRef.current) { - throw new Error('Email recipient picker already open'); - } - - setEmailRecipientModalOpen(true); - return new Promise((resolve, reject) => { - emailRecipientPromiseRef.current = { resolve, reject }; - }); - }, [config]); - - const handleEmailRecipientSelect = useCallback((nickname: string) => { - const pending = emailRecipientPromiseRef.current; - emailRecipientPromiseRef.current = null; - setEmailRecipientModalOpen(false); - pending?.resolve(nickname); - }, []); - - const handleEmailRecipientCancel = useCallback(() => { - const pending = emailRecipientPromiseRef.current; - emailRecipientPromiseRef.current = null; - setEmailRecipientModalOpen(false); - pending?.reject(new UserCancelledError()); - }, []); // Expose debug function to trigger onboarding from browser console useEffect(() => { @@ -482,18 +440,9 @@ function App() { // Download book const handleDownload = async (book: Book): Promise => { try { - let emailRecipient: string | undefined; - if (config?.books_output_mode === 'email' && contentType === 'ebook') { - emailRecipient = await openEmailRecipientPicker(); - } - - await downloadBook(book.id, emailRecipient); + await downloadBook(book.id); await fetchStatus(); } catch (error) { - if (isUserCancelledError(error)) { - // Important: rethrow so buttons can reset their "Queuing..." state. - throw error; - } console.error('Download failed:', error); showToast(error instanceof Error ? error.message : 'Failed to queue download', 'error'); throw error; @@ -546,11 +495,6 @@ function App() { // Handle download from ReleaseModal const handleReleaseDownload = async (book: Book, release: Release, releaseContentType: ContentType) => { try { - let emailRecipient: string | undefined; - if (config?.books_output_mode === 'email' && releaseContentType === 'ebook') { - emailRecipient = await openEmailRecipientPicker(); - } - trackRelease(book.id, release.source_id); await downloadRelease({ @@ -572,13 +516,9 @@ function App() { series_name: book.series_name, series_position: book.series_position, subtitle: book.subtitle, - email_recipient: emailRecipient, }); await fetchStatus(); } catch (error) { - if (isUserCancelledError(error)) { - throw error; - } console.error('Release download failed:', error); showToast(error instanceof Error ? error.message : 'Failed to queue download', 'error'); throw error; @@ -630,13 +570,16 @@ function App() { searchInput={searchInput} onSearchChange={setSearchInput} onDownloadsClick={() => setDownloadsSidebarOpen(true)} - onSettingsClick={isAdmin ? () => { + onSettingsClick={() => { if (config?.settings_enabled) { setSettingsOpen(true); } else { setConfigBannerOpen(true); } - } : undefined} + }} + isAdmin={isAdmin} + username={username} + displayName={displayName} statusCounts={statusCounts} onLogoClick={() => handleResetSearch(config)} authRequired={authRequired} @@ -750,13 +693,6 @@ function App() { /> )} - -