Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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=

Expand Down
90 changes: 55 additions & 35 deletions api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
24 changes: 24 additions & 0 deletions docs/5-CONFIGURATION/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <password>`.
- 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
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down