diff --git a/Dockerfile b/Dockerfile index 7b9cd61..fa70f1a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -63,7 +63,6 @@ COPY VERSION . COPY plugins ./plugins COPY themes ./themes COPY locales ./locales -COPY generate_password.py . # Create data directory RUN mkdir -p data diff --git a/README.md b/README.md index e71f591..b633524 100644 --- a/README.md +++ b/README.md @@ -267,7 +267,6 @@ NoteDiscovery is designed for **self-hosted, private use**. Please keep these se - āš ļø **ENABLE AUTHENTICATION AND CHANGE THE DEFAULT PASSWORD** if exposing to a network! - See **[AUTHENTICATION.md](documentation/AUTHENTICATION.md)** for complete setup instructions - To disable auth, set `authentication.enabled: false` in `config.yaml` -- Change password with Docker: `docker-compose exec notediscovery python generate_password.py` - Perfect for single-user or small team deployments - For multi-user setups, consider a reverse proxy with OAuth/SSO diff --git a/backend/main.py b/backend/main.py index c5b3419..e327bfe 100644 --- a/backend/main.py +++ b/backend/main.py @@ -7,6 +7,7 @@ from fastapi import FastAPI, HTTPException, UploadFile, File, Request, Form, Dep from fastapi.staticfiles import StaticFiles from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, RedirectResponse from fastapi.middleware.cors import CORSMiddleware +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials, APIKeyHeader from starlette.middleware.sessions import SessionMiddleware import os import yaml @@ -16,6 +17,7 @@ from typing import List, Optional import aiofiles from datetime import datetime import bcrypt +import secrets from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi.util import get_remote_address from slowapi.errors import RateLimitExceeded @@ -78,39 +80,65 @@ else: print(f"šŸ” Authentication {'ENABLED' if config.get('authentication', {}).get('enabled', False) else 'DISABLED'} (from config.yaml)") # Password configuration priority: -# 1. AUTHENTICATION_PASSWORD env var (plain text, hashed at startup) -# 2. AUTHENTICATION_PASSWORD_HASH env var (pre-hashed) -# 3. authentication.password in config.yaml (plain text, hashed at startup) -# 4. authentication.password_hash in config.yaml (pre-hashed) +# 1. AUTHENTICATION_PASSWORD env var (hashed at startup) +# 2. authentication.password in config.yaml (hashed at startup) # Default password is "admin" if nothing is configured if 'AUTHENTICATION_PASSWORD' in os.environ: - # Plain text password from env var - hash it - plain_password = os.getenv('AUTHENTICATION_PASSWORD') + plain_password = os.getenv('AUTHENTICATION_PASSWORD', '').strip() + if plain_password: + config['authentication']['password_hash'] = bcrypt.hashpw( + plain_password.encode('utf-8'), + bcrypt.gensalt() + ).decode('utf-8') + print("šŸ”‘ Password loaded from AUTHENTICATION_PASSWORD env var") + else: + print("āš ļø WARNING: AUTHENTICATION_PASSWORD env var is empty - ignoring") +elif config.get('authentication', {}).get('password', '').strip(): + plain_password = config['authentication']['password'].strip() config['authentication']['password_hash'] = bcrypt.hashpw( plain_password.encode('utf-8'), bcrypt.gensalt() ).decode('utf-8') - print("šŸ”‘ Password loaded from AUTHENTICATION_PASSWORD env var (hashed at startup)") -elif 'AUTHENTICATION_PASSWORD_HASH' in os.environ: - # Pre-hashed password from env var - config['authentication']['password_hash'] = os.getenv('AUTHENTICATION_PASSWORD_HASH') - print("šŸ”‘ Password hash loaded from AUTHENTICATION_PASSWORD_HASH env var") -elif config.get('authentication', {}).get('password'): - # Plain text password from config.yaml - hash it - plain_password = config['authentication']['password'] - config['authentication']['password_hash'] = bcrypt.hashpw( - plain_password.encode('utf-8'), - bcrypt.gensalt() - ).decode('utf-8') - # Clear the plain text password from config for security del config['authentication']['password'] - print("šŸ”‘ Password loaded from config.yaml (hashed at startup)") + print("šŸ”‘ Password loaded from config.yaml") # Allow secret key to be set via environment variable (for session security) if 'AUTHENTICATION_SECRET_KEY' in os.environ: config['authentication']['secret_key'] = os.getenv('AUTHENTICATION_SECRET_KEY') print("šŸ” Secret key loaded from AUTHENTICATION_SECRET_KEY env var") +# API key configuration for external integrations (MCP servers, scripts, etc.) +# Priority: AUTHENTICATION_API_KEY env var > authentication.api_key in config.yaml +if 'AUTHENTICATION_API_KEY' in os.environ: + api_key_value = os.getenv('AUTHENTICATION_API_KEY', '').strip() + if api_key_value: + config['authentication']['api_key'] = api_key_value + print("šŸ”‘ API key loaded from AUTHENTICATION_API_KEY env var") + else: + config['authentication']['api_key'] = '' +elif config.get('authentication', {}).get('api_key', '').strip(): + print("šŸ”‘ API key loaded from config.yaml") +else: + config['authentication']['api_key'] = '' + +# Warnings for missing authentication methods (only when auth is enabled) +if config.get('authentication', {}).get('enabled', False): + _has_password = bool(config.get('authentication', {}).get('password_hash', '')) + _has_api_key = bool(config.get('authentication', {}).get('api_key', '').strip()) + _secret_key = config.get('authentication', {}).get('secret_key', '') + _is_default_secret = _secret_key in ('', 'change_this_to_a_random_secret_key_in_production') + + if not _has_password and not _has_api_key: + print("🚨 CRITICAL: Authentication enabled but NO auth methods configured - ALL access will be denied!") + else: + if not _has_password: + print("āš ļø WARNING: No password configured - web UI login will not work") + if not _has_api_key: + print("āš ļø WARNING: No API key configured - external integrations will require session cookies") + + if _is_default_secret: + print("🚨 SECURITY WARNING: Using default secret_key - sessions can be forged! Change it in config.yaml") + # OpenAPI tag metadata for grouping endpoints in Swagger UI tags_metadata = [ {"name": "Notes", "description": "Create, read, update, delete notes"}, @@ -274,19 +302,88 @@ async def http_exception_handler(request: Request, exc: HTTPException): # Authentication Helpers # ============================================================================ +# Security schemes for API authentication (auto_error=False for optional auth) +# These are automatically added to OpenAPI docs (/api) +bearer_scheme = HTTPBearer(auto_error=False, description="Bearer token authentication") +api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False, description="API key header authentication") + + def auth_enabled() -> bool: """Check if authentication is enabled in config""" return config.get('authentication', {}).get('enabled', False) -async def require_auth(request: Request): - """Dependency to require authentication on protected routes""" +def get_api_key() -> str: + """Get the configured API key (empty string if not set)""" + return config.get('authentication', {}).get('api_key', '').strip() + + +def verify_api_key(provided_key: str) -> bool: + """ + Verify an API key using constant-time comparison. + + Uses secrets.compare_digest to prevent timing attacks where an attacker + could determine the correct key by measuring response times. + + Args: + provided_key: The API key provided in the request + + Returns: + True if the key is valid, False otherwise + """ + configured_key = get_api_key() + + # No API key configured = API key auth disabled + if not configured_key: + return False + + # Empty provided key is always invalid + if not provided_key: + return False + + # Constant-time comparison to prevent timing attacks + try: + return secrets.compare_digest(provided_key.encode('utf-8'), configured_key.encode('utf-8')) + except Exception: + return False + + +async def require_auth( + request: Request, + bearer_credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme), + x_api_key: Optional[str] = Depends(api_key_header) +): + """ + Dependency to require authentication on protected routes. + + Supports two authentication methods: + 1. Session-based auth (web UI login with password) + 2. API key auth (for external integrations like MCP servers) + + API key can be provided via: + - Authorization: Bearer YOUR_API_KEY + - X-API-Key: YOUR_API_KEY + + Raises: + HTTPException: 401 if authentication fails + """ if not auth_enabled(): return # Auth disabled, allow all - if not request.session.get('authenticated'): - # Always raise exception - route handlers will catch and redirect as needed - raise HTTPException(status_code=401, detail="Not authenticated") + # Method 1: Check Bearer token (parsed by FastAPI's HTTPBearer) + if bearer_credentials and verify_api_key(bearer_credentials.credentials): + return # Valid Bearer token - authenticated + + # Method 2: Check X-API-Key header (parsed by FastAPI's APIKeyHeader) + if x_api_key and verify_api_key(x_api_key): + return # Valid API key header - authenticated + + # Method 3: Check session-based authentication (web UI) + if request.session.get('authenticated'): + return # Valid session - authenticated + + # No valid authentication method - deny access + raise HTTPException(status_code=401, detail="Not authenticated") def verify_password(password: str) -> bool: diff --git a/config.yaml b/config.yaml index 5a3653a..a62458a 100644 --- a/config.yaml +++ b/config.yaml @@ -35,17 +35,21 @@ authentication: # Generate with: python -c "import secrets; print(secrets.token_hex(32))" secret_key: "change_this_to_a_random_secret_key_in_production" - # Password configuration - choose ONE of the following options: - # - # Option 1: Plain text password (recommended for ease of use) - # The password is hashed automatically at startup + # Password (hashed automatically at startup) # āš ļø Default password is "admin" - CHANGE THIS for production! password: "admin" - # Option 2: Pre-hashed password (for advanced users) - # Generate with: python generate_password.py - # password_hash: "$2b$12$..." - # Session expiry in seconds (default: 7 days) session_max_age: 604800 + + # API Key Authentication (for external integrations) + # Usage (choose one): + # Authorization: Bearer YOUR_API_KEY + # X-API-Key: YOUR_API_KEY + # + # Generate a secure key with: + # python -c "import secrets; print(secrets.token_hex(32))" + # + # Leave empty to disable API key authentication (only session auth works) + api_key: "" diff --git a/documentation/AUTHENTICATION.md b/documentation/AUTHENTICATION.md index 7d5c6b7..404fe80 100644 --- a/documentation/AUTHENTICATION.md +++ b/documentation/AUTHENTICATION.md @@ -50,11 +50,7 @@ python -c "import secrets; print(secrets.token_hex(32))" ### Step 2: Configure Authentication -Choose **one** of these options: - -#### Option A: Plain Text Password (Recommended) - -The easiest approach. Your password is automatically hashed at startup. +Your password is automatically hashed at startup using bcrypt. **Via Environment Variables (Docker):** ```bash @@ -75,29 +71,6 @@ authentication: --- -#### Option B: Pre-Hashed Password (Advanced) - -For users who prefer to hash passwords themselves. - -**Generate a hash:** -```bash -# Docker -docker exec -it notediscovery python generate_password.py - -# Local -python generate_password.py -``` - -**Then configure:** -```yaml -authentication: - enabled: true - password_hash: "$2b$12$..." # paste your hash here - secret_key: "your_generated_secret_key" -``` - ---- - ### Step 3: Restart & Test ```bash @@ -117,16 +90,14 @@ Navigate to `http://localhost:8000` — you'll be redirected to the login page. ## Configuration Priority -If multiple sources are configured, this priority applies (first wins): +Environment variables override config.yaml: -| Priority | Source | Type | -|----------|--------|------| -| 1st | `AUTHENTICATION_PASSWORD` env var | Plain text | -| 2nd | `AUTHENTICATION_PASSWORD_HASH` env var | Pre-hashed | -| 3rd | `password` in config.yaml | Plain text | -| 4th | `password_hash` in config.yaml | Pre-hashed | +| Priority | Source | +|----------|--------| +| 1st | `AUTHENTICATION_PASSWORD` env var | +| 2nd | `password` in config.yaml | -**Example:** If you set `AUTHENTICATION_PASSWORD` as an env var, it overrides anything in config.yaml. +**Example:** If you set `AUTHENTICATION_PASSWORD` as an env var, it overrides config.yaml. --- @@ -155,6 +126,42 @@ This is a **simple single-user** system. NOT suitable for: --- +## API Key Authentication + +For external integrations (MCP servers, scripts, automation), use an API key instead of session cookies. + +### Setup + +```bash +# Generate a secure key +python -c "import secrets; print(secrets.token_hex(32))" +``` + +**Via Environment Variable:** +```bash +docker run -e AUTHENTICATION_API_KEY=your_api_key ... +``` + +**Via config.yaml:** +```yaml +authentication: + api_key: "your_64_character_hex_key" +``` + +### Usage + +```bash +# Option 1: Bearer token +curl -H "Authorization: Bearer YOUR_API_KEY" http://localhost:8000/api/notes + +# Option 2: X-API-Key header +curl -H "X-API-Key: YOUR_API_KEY" http://localhost:8000/api/notes +``` + +Both session auth (web UI) and API key auth work simultaneously when enabled. + +--- + ## Disabling Authentication ```yaml diff --git a/documentation/ENVIRONMENT_VARIABLES.md b/documentation/ENVIRONMENT_VARIABLES.md index c0c7a29..45dae01 100644 --- a/documentation/ENVIRONMENT_VARIABLES.md +++ b/documentation/ENVIRONMENT_VARIABLES.md @@ -17,11 +17,9 @@ NoteDiscovery supports environment variables to override configuration settings, | Variable | Type | Default | Description | |----------|------|---------|-------------| | `AUTHENTICATION_ENABLED` | boolean | `config.yaml` | Enable/disable authentication | -| `AUTHENTICATION_PASSWORD` | string | `admin` | Plain text password (hashed automatically at startup) | -| `AUTHENTICATION_PASSWORD_HASH` | string | - | Pre-hashed bcrypt password (for advanced users) | +| `AUTHENTICATION_PASSWORD` | string | `admin` | Password (hashed automatically at startup) | | `AUTHENTICATION_SECRET_KEY` | string | `config.yaml` | Session secret key (for session security) | - -> **Password Priority:** `AUTHENTICATION_PASSWORD` takes precedence over `AUTHENTICATION_PASSWORD_HASH`. If both are set, the plain text password is used. +| `AUTHENTICATION_API_KEY` | string | - | API key for external integrations (MCP, scripts) | #### Example: Setting password via environment variable diff --git a/documentation/FEATURES.md b/documentation/FEATURES.md index b1d8833..8cdc366 100644 --- a/documentation/FEATURES.md +++ b/documentation/FEATURES.md @@ -347,6 +347,15 @@ NoteDiscovery can be installed as a standalone app on your device: - **Instant switch** - Change language in Settings without reload - **Community translations** - Contributions welcome! +## šŸ” Authentication + +- **Password protection** - Single-user login system +- **Session-based auth** - Secure cookie-based sessions (7 days default) +- **API key support** - Bearer token or `X-API-Key` header for external integrations +- **Environment overrides** - Configure via env vars for Docker deployments + +šŸ“„ **See [AUTHENTICATION.md](AUTHENTICATION.md)** for setup guide. + ## šŸš€ Performance - **Instant loading** - No lag, no loading spinners diff --git a/generate_password.py b/generate_password.py deleted file mode 100644 index e6780ab..0000000 --- a/generate_password.py +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env python3 -""" -Helper script to generate a password hash for NoteDiscovery authentication. -Usage: python generate_password.py -""" - -import getpass -import bcrypt - -def generate_password_hash(): - """Generate a bcrypt password hash""" - print("=== NoteDiscovery Password Hash Generator ===\n") - - # Get password from user (hidden input) - password = getpass.getpass("Enter your password: ") - password_confirm = getpass.getpass("Confirm your password: ") - - # Check if passwords match - if password != password_confirm: - print("\nāŒ Error: Passwords do not match!") - return - - if len(password) < 4: - print("\nāš ļø Warning: Password is too short! Use at least 8 characters for better security.") - proceed = input("Continue anyway? (y/N): ") - if proceed.lower() != 'y': - return - - # Generate hash - print("\nšŸ” Generating password hash...") - password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') - - print("\nāœ… Password hash generated successfully!") - print("\n" + "="*60) - print("Copy this hash to your config.yaml:") - print("="*60) - print(f"\npassword_hash: \"{password_hash}\"") - print("\n" + "="*60) - print("\nExample config.yaml:") - print("="*60) - print(""" -authentication: - enabled: true - secret_key: "your_secret_key_here" - password_hash: "{}" - session_max_age: 604800 -""".format(password_hash)) - print("="*60) - - print("\nšŸ’” Don't forget to also set a secret key!") - print(" Generate one with: python -c \"import secrets; print(secrets.token_hex(32))\"") - print("\nšŸ”’ Keep your password and secret key secure!") - -if __name__ == "__main__": - try: - generate_password_hash() - except KeyboardInterrupt: - print("\n\nāŒ Cancelled.") - except Exception as e: - print(f"\nāŒ Error: {e}") - diff --git a/render.yaml b/render.yaml index 2a43236..0fca339 100644 --- a/render.yaml +++ b/render.yaml @@ -27,9 +27,8 @@ services: # āš ļø DEMO CREDENTIALS - DO NOT USE IN PRODUCTION! āš ļø # These are public demo credentials for testing only. - # Password: "admin" - - key: AUTHENTICATION_PASSWORD_HASH - value: "$2b$12$t/6PGExFzdpU2PUta0iVY.eDQwvu63kH.c/d4bEnnHaQ5CspH1yrG" + - key: AUTHENTICATION_PASSWORD + value: "admin" # āš ļø PUBLIC SECRET KEY - DEMO ONLY! āš ļø # For production, generate a new key with: