Skip to content

Commit f597bd2

Browse files
author
Liyi Meng
committed
Support bcryt password
1 parent e69485b commit f597bd2

File tree

4 files changed

+86
-37
lines changed

4 files changed

+86
-37
lines changed

.env.example

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ API_URL=http://localhost:5055
8888

8989
# SECURITY
9090
# Set this to protect your Open Notebook instance with a password (for public hosting)
91+
# You may supply either a plaintext password or a bcrypt hash.
92+
# Examples:
93+
# Plaintext:
94+
# OPEN_NOTEBOOK_PASSWORD=your_secure_password
95+
# bcrypt hash (server must have bcrypt installed):
96+
# OPEN_NOTEBOOK_PASSWORD='$2b$12$K1...'
9197
# OPEN_NOTEBOOK_PASSWORD=
9298

9399
# OPENAI
@@ -250,8 +256,6 @@ SURREAL_COMMANDS_RETRY_WAIT_MAX=30
250256
# backoff ensures operations complete successfully even at high concurrency.
251257
SURREAL_COMMANDS_MAX_TASKS=5
252258

253-
# OPEN_NOTEBOOK_PASSWORD=
254-
255259
# FIRECRAWL - Get a key at https://firecrawl.dev/
256260
FIRECRAWL_API_KEY=
257261

api/auth.py

Lines changed: 55 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,64 +6,88 @@
66
from starlette.middleware.base import BaseHTTPMiddleware
77
from starlette.responses import JSONResponse
88

9+
import bcrypt
10+
11+
12+
def verify_password(provided: str, stored: str) -> bool:
13+
"""
14+
Verify a provided plaintext password against the stored value.
15+
16+
- If `stored` looks like a bcrypt hash (starts with "$2"), use bcrypt.checkpw.
17+
- Otherwise treat `stored` as a plaintext secret and compare directly.
18+
19+
Returns True if the password is valid, False otherwise.
20+
"""
21+
if not stored:
22+
return False
23+
24+
# bcrypt-style hashes begin with "$2b$", "$2a$", "$2y$", etc.
25+
if isinstance(stored, str) and stored.startswith("$2"):
26+
try:
27+
return bcrypt.checkpw(provided.encode("utf-8"), stored.encode("utf-8"))
28+
except Exception:
29+
# Any error in bcrypt verification should be treated as an invalid password
30+
return False
31+
32+
# Plaintext comparison
33+
return provided == stored
34+
935

1036
class PasswordAuthMiddleware(BaseHTTPMiddleware):
1137
"""
1238
Middleware to check password authentication for all API requests.
13-
Only active when OPEN_NOTEBOOK_PASSWORD environment variable is set.
39+
Active when OPEN_NOTEBOOK_PASSWORD environment variable is set.
40+
41+
Behavior:
42+
- If OPEN_NOTEBOOK_PASSWORD starts with "$2" it's treated as a bcrypt hash and
43+
incoming Bearer tokens are verified using verify_password().
44+
- Otherwise the value is treated as a plaintext secret and compared directly.
1445
"""
15-
46+
1647
def __init__(self, app, excluded_paths: Optional[list] = None):
1748
super().__init__(app)
1849
self.password = os.environ.get("OPEN_NOTEBOOK_PASSWORD")
50+
self.password_is_hash = bool(self.password and self.password.startswith("$2"))
1951
self.excluded_paths = excluded_paths or ["/", "/health", "/docs", "/openapi.json", "/redoc"]
20-
52+
2153
async def dispatch(self, request: Request, call_next):
22-
# Skip authentication if no password is set
54+
# No auth configured
2355
if not self.password:
2456
return await call_next(request)
25-
26-
# Skip authentication for excluded paths
27-
if request.url.path in self.excluded_paths:
28-
return await call_next(request)
29-
30-
# Skip authentication for CORS preflight requests (OPTIONS)
31-
if request.method == "OPTIONS":
57+
58+
# Skip authentication for excluded or preflight requests
59+
if request.url.path in self.excluded_paths or request.method == "OPTIONS":
3260
return await call_next(request)
33-
34-
# Check authorization header
61+
3562
auth_header = request.headers.get("Authorization")
36-
3763
if not auth_header:
3864
return JSONResponse(
3965
status_code=401,
4066
content={"detail": "Missing authorization header"},
4167
headers={"WWW-Authenticate": "Bearer"}
4268
)
43-
69+
4470
# Expected format: "Bearer {password}"
4571
try:
4672
scheme, credentials = auth_header.split(" ", 1)
4773
if scheme.lower() != "bearer":
48-
raise ValueError("Invalid authentication scheme")
74+
raise ValueError()
4975
except ValueError:
5076
return JSONResponse(
5177
status_code=401,
5278
content={"detail": "Invalid authorization header format"},
5379
headers={"WWW-Authenticate": "Bearer"}
5480
)
55-
56-
# Check password
57-
if credentials != self.password:
81+
82+
# Verify password via helper
83+
if not verify_password(credentials, self.password):
5884
return JSONResponse(
5985
status_code=401,
6086
content={"detail": "Invalid password"},
6187
headers={"WWW-Authenticate": "Bearer"}
6288
)
63-
64-
# Password is correct, proceed with the request
65-
response = await call_next(request)
66-
return response
89+
90+
return await call_next(request)
6791

6892

6993
# Optional: HTTPBearer security scheme for OpenAPI documentation
@@ -72,29 +96,25 @@ async def dispatch(self, request: Request, call_next):
7296

7397
def check_api_password(credentials: Optional[HTTPAuthorizationCredentials] = None) -> bool:
7498
"""
75-
Utility function to check API password.
76-
Can be used as a dependency in individual routes if needed.
99+
Dependency utility to verify the API password for individual routes.
100+
Uses verify_password() for the actual check.
77101
"""
78-
password = os.environ.get("OPEN_NOTEBOOK_PASSWORD")
79-
80-
# No password set, allow access
81-
if not password:
102+
password_env = os.environ.get("OPEN_NOTEBOOK_PASSWORD")
103+
if not password_env:
82104
return True
83-
84-
# No credentials provided
105+
85106
if not credentials:
86107
raise HTTPException(
87108
status_code=401,
88109
detail="Missing authorization",
89110
headers={"WWW-Authenticate": "Bearer"},
90111
)
91-
92-
# Check password
93-
if credentials.credentials != password:
112+
113+
if not verify_password(credentials.credentials, password_env):
94114
raise HTTPException(
95115
status_code=401,
96116
detail="Invalid password",
97117
headers={"WWW-Authenticate": "Bearer"},
98118
)
99-
119+
100120
return True

docs/5-CONFIGURATION/security.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,30 @@ OPEN_NOTEBOOK_PASSWORD=Notebook$Dev$2024$Strong!
6363
OPEN_NOTEBOOK_PASSWORD=$(openssl rand -base64 24)
6464
```
6565

66+
### Hashed password (optional)
67+
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.
68+
69+
To generate a bcrypt hash locally (example using Python and the bcrypt package):
70+
71+
```bash
72+
# Install bcrypt locally
73+
pip install bcrypt
74+
75+
# Generate bcrypt hash (prints the hash)
76+
python -c "import bcrypt,sys;print(bcrypt.hashpw(sys.argv[1].encode(),bcrypt.gensalt()).decode())" yourpassword
77+
```
78+
79+
Then set the environment variable to the printed hash:
80+
81+
```bash
82+
OPEN_NOTEBOOK_PASSWORD='$2b$12$K1...' (paste the generated hash)
83+
```
84+
85+
Notes:
86+
- The frontend and API clients still send the plaintext password in `Authorization: Bearer <password>`.
87+
- The server compares that plaintext password against the stored bcrypt hash. You must have `bcrypt` installed on the server for hashed-mode to work.
88+
89+
6690
### Bad Passwords
6791

6892
```bash

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ dependencies = [
3939
"surrealdb>=1.0.4",
4040
"podcast-creator>=0.7.0",
4141
"surreal-commands>=1.3.0",
42+
"bcrypt>=4.0.0",
4243
]
4344

4445
[tool.setuptools]

0 commit comments

Comments
 (0)