diff --git a/.env.example b/.env.example index c83f1ee9..cfb95383 100644 --- a/.env.example +++ b/.env.example @@ -88,6 +88,12 @@ API_URL=http://localhost:5055 # SECURITY # Set this to protect your Open Notebook instance with a password (for public hosting) +# You may supply either a plaintext password or a bcrypt hash. +# Examples: +# Plaintext: +# OPEN_NOTEBOOK_PASSWORD=your_secure_password +# bcrypt hash (server must have bcrypt installed): +# OPEN_NOTEBOOK_PASSWORD='$2b$12$K1...' # OPEN_NOTEBOOK_PASSWORD= # OPENAI @@ -250,8 +256,6 @@ SURREAL_COMMANDS_RETRY_WAIT_MAX=30 # backoff ensures operations complete successfully even at high concurrency. SURREAL_COMMANDS_MAX_TASKS=5 -# OPEN_NOTEBOOK_PASSWORD= - # FIRECRAWL - Get a key at https://firecrawl.dev/ FIRECRAWL_API_KEY= diff --git a/api/auth.py b/api/auth.py index 04895c80..fab76c26 100644 --- a/api/auth.py +++ b/api/auth.py @@ -6,64 +6,88 @@ from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import JSONResponse +import bcrypt + + +def verify_password(provided: str, stored: str) -> bool: + """ + Verify a provided plaintext password against the stored value. + + - If `stored` looks like a bcrypt hash (starts with "$2"), use bcrypt.checkpw. + - Otherwise treat `stored` as a plaintext secret and compare directly. + + Returns True if the password is valid, False otherwise. + """ + if not stored: + return False + + # bcrypt-style hashes begin with "$2b$", "$2a$", "$2y$", etc. + if isinstance(stored, str) and stored.startswith("$2"): + try: + return bcrypt.checkpw(provided.encode("utf-8"), stored.encode("utf-8")) + except Exception: + # Any error in bcrypt verification should be treated as an invalid password + return False + + # Plaintext comparison + return secrets.compare_digest(provided, stored) + class PasswordAuthMiddleware(BaseHTTPMiddleware): """ Middleware to check password authentication for all API requests. - Only active when OPEN_NOTEBOOK_PASSWORD environment variable is set. + Active when OPEN_NOTEBOOK_PASSWORD environment variable is set. + + Behavior: + - If OPEN_NOTEBOOK_PASSWORD starts with "$2" it's treated as a bcrypt hash and + incoming Bearer tokens are verified using verify_password(). + - Otherwise the value is treated as a plaintext secret and compared directly. """ - + def __init__(self, app, excluded_paths: Optional[list] = None): super().__init__(app) self.password = os.environ.get("OPEN_NOTEBOOK_PASSWORD") + self.password_is_hash = bool(self.password and self.password.startswith("$2")) self.excluded_paths = excluded_paths or ["/", "/health", "/docs", "/openapi.json", "/redoc"] - + async def dispatch(self, request: Request, call_next): - # Skip authentication if no password is set + # No auth configured if not self.password: return await call_next(request) - - # Skip authentication for excluded paths - if request.url.path in self.excluded_paths: - return await call_next(request) - - # Skip authentication for CORS preflight requests (OPTIONS) - if request.method == "OPTIONS": + + # Skip authentication for excluded or preflight requests + if request.url.path in self.excluded_paths or request.method == "OPTIONS": return await call_next(request) - - # Check authorization header + auth_header = request.headers.get("Authorization") - if not auth_header: return JSONResponse( status_code=401, content={"detail": "Missing authorization header"}, headers={"WWW-Authenticate": "Bearer"} ) - + # Expected format: "Bearer {password}" try: scheme, credentials = auth_header.split(" ", 1) if scheme.lower() != "bearer": - raise ValueError("Invalid authentication scheme") + raise ValueError() except ValueError: return JSONResponse( status_code=401, content={"detail": "Invalid authorization header format"}, headers={"WWW-Authenticate": "Bearer"} ) - - # Check password - if credentials != self.password: + + # Verify password via helper + if not verify_password(credentials, self.password): return JSONResponse( status_code=401, content={"detail": "Invalid password"}, headers={"WWW-Authenticate": "Bearer"} ) - - # Password is correct, proceed with the request - response = await call_next(request) - return response + + return await call_next(request) # Optional: HTTPBearer security scheme for OpenAPI documentation @@ -72,29 +96,25 @@ async def dispatch(self, request: Request, call_next): def check_api_password(credentials: Optional[HTTPAuthorizationCredentials] = None) -> bool: """ - Utility function to check API password. - Can be used as a dependency in individual routes if needed. + Dependency utility to verify the API password for individual routes. + Uses verify_password() for the actual check. """ - password = os.environ.get("OPEN_NOTEBOOK_PASSWORD") - - # No password set, allow access - if not password: + password_env = os.environ.get("OPEN_NOTEBOOK_PASSWORD") + if not password_env: return True - - # No credentials provided + if not credentials: raise HTTPException( status_code=401, detail="Missing authorization", headers={"WWW-Authenticate": "Bearer"}, ) - - # Check password - if credentials.credentials != password: + + if not verify_password(credentials.credentials, password_env): raise HTTPException( status_code=401, detail="Invalid password", headers={"WWW-Authenticate": "Bearer"}, ) - + return True \ No newline at end of file diff --git a/docs/5-CONFIGURATION/security.md b/docs/5-CONFIGURATION/security.md index ddf389d8..a8bc4389 100644 --- a/docs/5-CONFIGURATION/security.md +++ b/docs/5-CONFIGURATION/security.md @@ -63,6 +63,30 @@ OPEN_NOTEBOOK_PASSWORD=Notebook$Dev$2024$Strong! OPEN_NOTEBOOK_PASSWORD=$(openssl rand -base64 24) ``` +### Hashed password (optional) +You can store a bcrypt hash in the OPEN_NOTEBOOK_PASSWORD environment variable instead of the plaintext secret. The server will detect a bcrypt-style hash (strings beginning with `$2`) and verify incoming Bearer tokens against that hash. + +To generate a bcrypt hash locally (example using Python and the bcrypt package): + +```bash +# Install bcrypt locally +pip install bcrypt + +# Generate bcrypt hash (prints the hash) +python -c "import bcrypt,sys;print(bcrypt.hashpw(sys.argv[1].encode(),bcrypt.gensalt()).decode())" yourpassword +``` + +Then set the environment variable to the printed hash: + +```bash +OPEN_NOTEBOOK_PASSWORD='$2b$12$K1...' (paste the generated hash) +``` + +Notes: +- The frontend and API clients still send the plaintext password in `Authorization: Bearer `. +- The server compares that plaintext password against the stored bcrypt hash. You must have `bcrypt` installed on the server for hashed-mode to work. + + ### Bad Passwords ```bash diff --git a/pyproject.toml b/pyproject.toml index 87fb0a58..d581e3fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dependencies = [ "surrealdb>=1.0.4", "podcast-creator>=0.7.0", "surreal-commands>=1.3.0", + "bcrypt>=4.0.0", ] [tool.setuptools]