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 """
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
7398def 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
0 commit comments