Skip to content

Commit 0a9c41e

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

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: 56 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,64 +6,89 @@
66
from starlette.middleware.base import BaseHTTPMiddleware
77
from starlette.responses import JSONResponse
88

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

1038
class PasswordAuthMiddleware(BaseHTTPMiddleware):
1139
"""
1240
Middleware to check password authentication for all API requests.
13-
Only active when OPEN_NOTEBOOK_PASSWORD environment variable is set.
41+
Active when OPEN_NOTEBOOK_PASSWORD environment variable is set.
42+
43+
Behavior:
44+
- If OPEN_NOTEBOOK_PASSWORD starts with "$2" it's treated as a bcrypt hash and
45+
incoming Bearer tokens are verified using verify_password().
46+
- Otherwise the value is treated as a plaintext secret and compared directly.
1447
"""
15-
48+
1649
def __init__(self, app, excluded_paths: Optional[list] = None):
1750
super().__init__(app)
1851
self.password = os.environ.get("OPEN_NOTEBOOK_PASSWORD")
1952
self.excluded_paths = excluded_paths or ["/", "/health", "/docs", "/openapi.json", "/redoc"]
20-
53+
2154
async def dispatch(self, request: Request, call_next):
22-
# Skip authentication if no password is set
55+
# No auth configured
2356
if not self.password:
2457
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":
58+
59+
# Skip authentication for excluded or preflight requests
60+
if request.url.path in self.excluded_paths or request.method == "OPTIONS":
3261
return await call_next(request)
33-
34-
# Check authorization header
62+
3563
auth_header = request.headers.get("Authorization")
36-
3764
if not auth_header:
3865
return JSONResponse(
3966
status_code=401,
4067
content={"detail": "Missing authorization header"},
4168
headers={"WWW-Authenticate": "Bearer"}
4269
)
43-
70+
4471
# Expected format: "Bearer {password}"
4572
try:
4673
scheme, credentials = auth_header.split(" ", 1)
4774
if scheme.lower() != "bearer":
48-
raise ValueError("Invalid authentication scheme")
75+
raise ValueError()
4976
except ValueError:
5077
return JSONResponse(
5178
status_code=401,
5279
content={"detail": "Invalid authorization header format"},
5380
headers={"WWW-Authenticate": "Bearer"}
5481
)
55-
56-
# Check password
57-
if credentials != self.password:
82+
83+
# Verify password via helper
84+
if not verify_password(credentials, self.password):
5885
return JSONResponse(
5986
status_code=401,
6087
content={"detail": "Invalid password"},
6188
headers={"WWW-Authenticate": "Bearer"}
6289
)
63-
64-
# Password is correct, proceed with the request
65-
response = await call_next(request)
66-
return response
90+
91+
return await call_next(request)
6792

6893

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

7398
def check_api_password(credentials: Optional[HTTPAuthorizationCredentials] = None) -> bool:
7499
"""
75-
Utility function to check API password.
76-
Can be used as a dependency in individual routes if needed.
100+
Dependency utility to verify the API password for individual routes.
101+
Uses verify_password() for the actual check.
77102
"""
78-
password = os.environ.get("OPEN_NOTEBOOK_PASSWORD")
79-
80-
# No password set, allow access
81-
if not password:
103+
password_env = os.environ.get("OPEN_NOTEBOOK_PASSWORD")
104+
if not password_env:
82105
return True
83-
84-
# No credentials provided
106+
85107
if not credentials:
86108
raise HTTPException(
87109
status_code=401,
88110
detail="Missing authorization",
89111
headers={"WWW-Authenticate": "Bearer"},
90112
)
91-
92-
# Check password
93-
if credentials.credentials != password:
113+
114+
if not verify_password(credentials.credentials, password_env):
94115
raise HTTPException(
95116
status_code=401,
96117
detail="Invalid password",
97118
headers={"WWW-Authenticate": "Bearer"},
98119
)
99-
120+
100121
return True

docs/5-CONFIGURATION/security.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,29 @@ 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.
88+
6689
### Bad Passwords
6790

6891
```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)