66from starlette .middleware .base import BaseHTTPMiddleware
77from 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
1036class 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
7397def 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
0 commit comments