66from starlette .middleware .base import BaseHTTPMiddleware
77from 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
1038class 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 """
1548
1649 def __init__ (self , app , excluded_paths : Optional [list ] = None ):
@@ -25,7 +58,7 @@ def __init__(self, app, excluded_paths: Optional[list] = None):
2558 ]
2659
2760 async def dispatch (self , request : Request , call_next ):
28- # Skip authentication if no password is set
61+ # No auth configured
2962 if not self .password :
3063 return await call_next (request )
3164
@@ -51,16 +84,16 @@ async def dispatch(self, request: Request, call_next):
5184 try :
5285 scheme , credentials = auth_header .split (" " , 1 )
5386 if scheme .lower () != "bearer" :
54- raise ValueError ("Invalid authentication scheme" )
87+ raise ValueError ()
5588 except ValueError :
5689 return JSONResponse (
5790 status_code = 401 ,
5891 content = {"detail" : "Invalid authorization header format" },
5992 headers = {"WWW-Authenticate" : "Bearer" },
6093 )
6194
62- # Check password
63- if credentials != self .password :
95+ # Verify password via helper
96+ if not verify_password ( credentials , self .password ) :
6497 return JSONResponse (
6598 status_code = 401 ,
6699 content = {"detail" : "Invalid password" },
@@ -80,25 +113,21 @@ def check_api_password(
80113 credentials : Optional [HTTPAuthorizationCredentials ] = Depends (security ),
81114) -> bool :
82115 """
83- Utility function to check API password.
84- Can be used as a dependency in individual routes if needed .
116+ Dependency utility to verify the API password for individual routes .
117+ Uses verify_password() for the actual check .
85118 """
86- password = os .environ .get ("OPEN_NOTEBOOK_PASSWORD" )
87-
88- # No password set, allow access
89- if not password :
119+ password_env = os .environ .get ("OPEN_NOTEBOOK_PASSWORD" )
120+ if not password_env :
90121 return True
91122
92- # No credentials provided
93123 if not credentials :
94124 raise HTTPException (
95125 status_code = 401 ,
96126 detail = "Missing authorization" ,
97127 headers = {"WWW-Authenticate" : "Bearer" },
98128 )
99129
100- # Check password
101- if credentials .credentials != password :
130+ if not verify_password (credentials .credentials , password_env ):
102131 raise HTTPException (
103132 status_code = 401 ,
104133 detail = "Invalid password" ,
0 commit comments