added api-key/removed hashed password support

This commit is contained in:
Gamosoft 2026-03-13 11:50:47 +01:00
parent a175246a1e
commit bcdbb6b065
9 changed files with 190 additions and 139 deletions

View File

@ -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

View File

@ -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

View File

@ -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 (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']
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')
# 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,18 +302,87 @@ 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
# 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")

View File

@ -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: ""

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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}")

View File

@ -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: