5
5
6
6
.. versionadded:: 2.0
7
7
"""
8
+ import re
9
+ import uuid
8
10
from dataclasses import asdict , dataclass
9
- from typing import Any , Optional
11
+ from typing import Any , Dict , Optional
10
12
11
- from tornado . web import RequestHandler
13
+ from traitlets import Unicode , default
12
14
from traitlets .config import LoggingConfigurable
13
15
14
16
# from dataclasses import field
17
+ _JupyterHandler = "jupyter_server.base.handlers.JupyterHandler"
15
18
16
19
17
20
@dataclass
@@ -59,9 +62,6 @@ def fill_defaults(self):
59
62
if not self .display_name :
60
63
self .display_name = self .name
61
64
62
- def to_dict (self ):
63
- pass
64
-
65
65
66
66
def _backward_compat_user (got_user : Any ) -> User :
67
67
"""Backward-compatibility for LoginHandler.get_user
@@ -93,9 +93,7 @@ def _backward_compat_user(got_user: Any) -> User:
93
93
94
94
class IdentityProvider (LoggingConfigurable ):
95
95
"""
96
- Interface for providing identity
97
-
98
- _may_ be a coroutine.
96
+ Interface for providing identity management and authentication.
99
97
100
98
Two principle methods:
101
99
@@ -105,27 +103,103 @@ class IdentityProvider(LoggingConfigurable):
105
103
The default is to use :py:meth:`dataclasses.asdict`,
106
104
and usually shouldn't need override.
107
105
106
+ Additional methods can customize authentication.
107
+
108
108
.. versionadded:: 2.0
109
109
"""
110
110
111
- def get_user (self , handler : RequestHandler ) -> User :
111
+ cookie_name = Unicode (config = True )
112
+
113
+ token = Unicode (
114
+ "<generated>" ,
115
+ help = _i18n (
116
+ """Token used for authenticating first-time connections to the server.
117
+
118
+ The token can be read from the file referenced by JUPYTER_TOKEN_FILE or set directly
119
+ with the JUPYTER_TOKEN environment variable.
120
+
121
+ When no password is enabled,
122
+ the default is to generate a new, random token.
123
+
124
+ Setting to an empty string disables authentication altogether, which is NOT RECOMMENDED.
125
+
126
+ Prior to 2.0: configured as ServerApp.token
127
+ """
128
+ ),
129
+ ).tag (config = True )
130
+
131
+ login_handler_class = Type (
132
+ default_value = LoginHandler ,
133
+ klass = web .RequestHandler ,
134
+ config = True ,
135
+ help = _i18n ("The login handler class to use." ),
136
+ )
137
+
138
+ logout_handler_class = Type (
139
+ default_value = LogoutHandler ,
140
+ klass = web .RequestHandler ,
141
+ config = True ,
142
+ help = _i18n ("The logout handler class to use." ),
143
+ )
144
+
145
+ token_generated = False
146
+
147
+ @default ("token" )
148
+ def _token_default (self ):
149
+ if os .getenv ("JUPYTER_TOKEN" ):
150
+ self .token_generated = False
151
+ return os .getenv ("JUPYTER_TOKEN" )
152
+ if os .getenv ("JUPYTER_TOKEN_FILE" ):
153
+ self .token_generated = False
154
+ with open (os .getenv ("JUPYTER_TOKEN_FILE" )) as token_file :
155
+ return token_file .read ()
156
+ if self .password :
157
+ # no token if password is enabled
158
+ self .token_generated = False
159
+ return ""
160
+ else :
161
+ self .token_generated = True
162
+ return binascii .hexlify (os .urandom (24 )).decode ("ascii" )
163
+
164
+ def get_user (self , handler : _JupyterHandler ) -> Optional [User ]:
112
165
"""Get the authenticated user for a request
113
166
114
167
Must return a :class:`.jupyter_server.auth.User`,
115
168
though it may be a subclass.
116
169
117
170
Return None if the request is not authenticated.
118
- """
119
171
120
- if handler .login_handler is None :
121
- return User ("anonymous" )
172
+ _may_ be a coroutine
173
+ """
174
+ if getattr (handler , "_jupyter_current_user" , None ):
175
+ # already authenticated
176
+ return handler ._jupyter_current_user
177
+ token_user = self .get_user_token (handler )
178
+ cookie_user = self .get_user_cookie (handler )
179
+ # prefer token to cookie if both given,
180
+ # because token is always explicit
181
+ user = token_user or cookie_user
182
+ if token_user :
183
+ # if token-authenticated, persist user_id in cookie
184
+ # if it hasn't already been stored there
185
+ if user != cookie_user :
186
+ self .set_login_cookie (handler , user )
187
+ # Record that the current request has been authenticated with a token.
188
+ # Used in is_token_authenticated above.
189
+ handler ._token_authenticated = True
190
+
191
+ if user is None :
192
+ # If an invalid cookie was sent, clear it to prevent unnecessary
193
+ # extra warnings. But don't do this on a request with *no* cookie,
194
+ # because that can erroneously log you out (see gh-3365)
195
+ if handler .get_cookie (handler .cookie_name ) is not None :
196
+ handler .log .warning ("Clearing invalid/expired login cookie %s" , handler .cookie_name )
197
+ handler .clear_login_cookie ()
198
+ if not self .auth_enabled :
199
+ # Completely insecure! No authentication at all.
200
+ # No need to warn here, though; validate_security will have already done that.
201
+ user = self .generate_anonymous_user (handler )
122
202
123
- # The default: call LoginHandler.get_user for backward-compatibility
124
- # TODO: move default implementation to this class,
125
- # deprecate `LoginHandler.get_user`
126
- user = handler .login_handler .get_user (handler )
127
- if user and not isinstance (user , User ):
128
- return _backward_compat_user (user )
129
203
return user
130
204
131
205
def identity_model (self , user : User ) -> dict :
@@ -138,4 +212,190 @@ def get_handlers(self) -> list:
138
212
139
213
For example, an OAuth callback handler.
140
214
"""
141
- return []
215
+ handlers = []
216
+ if self .login_available :
217
+ handlers .append ((r"/login" , self .login_handler_class ))
218
+ if self .logout_available :
219
+ handlers .extend ((r"/logout" , self .logout_handler_class ))
220
+
221
+ def user_to_cookie (self , user : User ) -> str :
222
+ """Serialize a user to a string for storage in a cookie
223
+
224
+ If overriding in a subclass, make sure to define user_from_cookie as well.
225
+
226
+ Default is just the user's username.
227
+ """
228
+ # default: username is enough
229
+ return user .username
230
+
231
+ def user_from_cookie (self , cookie_value : str ) -> Optional [User ]:
232
+ """Inverse of user_to_cookie"""
233
+ return User (username = cookie_value )
234
+
235
+ def set_login_cookie (self , handler : _JupyterHandler , user : User ) -> None :
236
+ """Call this on handlers to set the login cookie for success"""
237
+ cookie_options = handler .settings .get ("cookie_options" , {})
238
+ cookie_options .setdefault ("httponly" , True )
239
+ # tornado <4.2 has a bug that considers secure==True as soon as
240
+ # 'secure' kwarg is passed to set_secure_cookie
241
+ if handler .settings .get ("secure_cookie" , handler .request .protocol == "https" ):
242
+ cookie_options .setdefault ("secure" , True )
243
+ cookie_options .setdefault ("path" , handler .base_url )
244
+ handler .set_secure_cookie (handler .cookie_name , self .user_to_cookie (user ), ** cookie_options )
245
+
246
+ auth_header_pat = re .compile (r"(token|bearer)\s+(.+)" , re .IGNORECASE )
247
+
248
+ def get_token (self , handler : _JupyterHandler ) -> Optional [str ]:
249
+ """Get the user token from a request
250
+
251
+ Default:
252
+
253
+ - in URL parameters: ?token=<token>
254
+ - in header: Authorization: token <token>
255
+ """
256
+
257
+ user_token = handler .get_argument ("token" , "" )
258
+ if not user_token :
259
+ # get it from Authorization header
260
+ m = self .auth_header_pat .match (handler .request .headers .get ("Authorization" , "" ))
261
+ if m :
262
+ user_token = m .group (1 )
263
+ return user_token
264
+
265
+ def get_user_cookie (self , handler : _JupyterHandler ) -> Optional [User ]:
266
+ """Get user from a cookie
267
+
268
+ Calls user_from_cookie to deserialize cookie value
269
+ """
270
+ get_secure_cookie_kwargs = handler .settings .get ("get_secure_cookie_kwargs" , {})
271
+ user_cookie = handler .get_secure_cookie (handler .cookie_name , ** get_secure_cookie_kwargs )
272
+ if not user_cookie :
273
+ return None
274
+ user_cookie = user_cookie .decode ()
275
+ # TODO: try/catch in case of change in config?
276
+ try :
277
+ return self .user_from_cookie (user_cookie )
278
+ except Exception as e :
279
+ # log bad cookie itself, only at debug-level
280
+ self .log .debug (f"Error unpacking user from cookie: cookie={ user_cookie } " , exc_info = True )
281
+ self .log .error (f"Error unpacking user from cookie: { e } " )
282
+ return None
283
+
284
+ def get_user_token (self , handler : _JupyterHandler ):
285
+ """Identify the user based on a token in the URL or Authorization header
286
+
287
+ Returns:
288
+ - uuid if authenticated
289
+ - None if not
290
+ """
291
+ token = handler .token
292
+ if not token :
293
+ return
294
+ # check login token from URL argument or Authorization header
295
+ user_token = self .get_token (handler )
296
+ authenticated = False
297
+ if user_token == token :
298
+ # token-authenticated, set the login cookie
299
+ handler .log .debug (
300
+ "Accepting token-authenticated connection from %s" ,
301
+ handler .request .remote_ip ,
302
+ )
303
+ authenticated = True
304
+
305
+ if authenticated :
306
+ # token does not correspond to user-id,
307
+ # which is stored in a cookie.
308
+ # still check the cookie for the user id
309
+ user = self .get_user_cookie (handler )
310
+ if user is None :
311
+ user = self .generate_anonymous_user (handler )
312
+ return user
313
+ else :
314
+ return None
315
+
316
+ def generate_anonymous_user (self , handler : _JupyterHandler ):
317
+ """Generate a random anonymous user"""
318
+ # no cookie, generate new random user_id
319
+ user_id = uuid .uuid4 ().hex
320
+ handler .log .info (f"Generating new user_id for token-authenticated request: { user_id } " )
321
+ return User (user_id )
322
+
323
+ def should_check_origin (self , handler : _JupyterHandler ) -> bool :
324
+ """Should the Handler check for CORS origin validation?
325
+
326
+ Origin check should be skipped for token-authenticated requests.
327
+
328
+ Returns:
329
+ - True, if Handler must check for valid CORS origin.
330
+ - False, if Handler should skip origin check since requests are token-authenticated.
331
+ """
332
+ return not self .is_token_authenticated (handler )
333
+
334
+ def is_token_authenticated (self , handler : _JupyterHandler ) -> bool :
335
+ """Returns True if handler has been token authenticated. Otherwise, False.
336
+
337
+ Login with a token is used to signal certain things, such as:
338
+
339
+ - permit access to REST API
340
+ - xsrf protection
341
+ - skip origin-checks for scripts
342
+ """
343
+ # ensure get_user has been called, so we know if we're token-authenticated
344
+ handler .current_user # noqa
345
+ return getattr (handler , "_token_authenticated" , False )
346
+
347
+ def validate_security (
348
+ self ,
349
+ app : "jupyter_server.serverapp.ServerApp" ,
350
+ ssl_options : Optional [Dict ] = None ,
351
+ ) -> None :
352
+ """Check the application's security.
353
+
354
+ Show messages, or abort if necessary, based on the security configuration.
355
+ """
356
+ if not app .ip :
357
+ warning = "WARNING: The Jupyter server is listening on all IP addresses"
358
+ if ssl_options is None :
359
+ app .log .warning (f"{ warning } and not using encryption. This is not recommended." )
360
+ if not self .auth_enabled :
361
+ app .log .warning (
362
+ f"{ warning } and not using authentication. "
363
+ "This is highly insecure and not recommended."
364
+ )
365
+ else :
366
+ if not self .auth_enabled :
367
+ app .log .warning (
368
+ "All authentication is disabled."
369
+ " Anyone who can connect to this server will be able to run code."
370
+ )
371
+
372
+ @property
373
+ def auth_enabled (self ):
374
+ """Is authentication enabled?
375
+
376
+ Should always be true, but may be False if requests with no auth are allowed.
377
+
378
+ Previously: LoginHandler.get_login_available
379
+ """
380
+ return True
381
+
382
+ @property
383
+ def login_available (self ):
384
+ """Whether a LoginHandler is needed - and therefore whether the login page should be displayed."""
385
+ return self .auth_enabled
386
+
387
+
388
+ class PasswordIdentityProvider (IdentityProvider ):
389
+
390
+ password = Unicode (help = """Hashed password""" , config = True )
391
+
392
+ @property
393
+ def login_available (self ):
394
+ """Whether a LoginHandler is needed - and therefore whether the login page should be displayed."""
395
+
396
+ return bool (self .password or self .token )
397
+
398
+ @property
399
+ def auth_enabled (self ):
400
+ """Return whether any auth is enabled"""
401
+ return bool (self .password or self .token )
0 commit comments