Skip to content

Commit 4e4b8cb

Browse files
committed
[WIP] migrate LoginHandler config to IdentityProvider
now that we have a configurable class to represent identity, most of the LoginHandler's custom methods belong there
1 parent 6d84507 commit 4e4b8cb

File tree

4 files changed

+323
-71
lines changed

4 files changed

+323
-71
lines changed

jupyter_server/auth/identity.py

Lines changed: 279 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@
55
66
.. versionadded:: 2.0
77
"""
8+
import re
9+
import uuid
810
from dataclasses import asdict, dataclass
9-
from typing import Any, Optional
11+
from typing import Any, Dict, Optional
1012

11-
from tornado.web import RequestHandler
13+
from traitlets import Unicode, default
1214
from traitlets.config import LoggingConfigurable
1315

1416
# from dataclasses import field
17+
_JupyterHandler = "jupyter_server.base.handlers.JupyterHandler"
1518

1619

1720
@dataclass
@@ -59,9 +62,6 @@ def fill_defaults(self):
5962
if not self.display_name:
6063
self.display_name = self.name
6164

62-
def to_dict(self):
63-
pass
64-
6565

6666
def _backward_compat_user(got_user: Any) -> User:
6767
"""Backward-compatibility for LoginHandler.get_user
@@ -93,9 +93,7 @@ def _backward_compat_user(got_user: Any) -> User:
9393

9494
class IdentityProvider(LoggingConfigurable):
9595
"""
96-
Interface for providing identity
97-
98-
_may_ be a coroutine.
96+
Interface for providing identity management and authentication.
9997
10098
Two principle methods:
10199
@@ -105,27 +103,103 @@ class IdentityProvider(LoggingConfigurable):
105103
The default is to use :py:meth:`dataclasses.asdict`,
106104
and usually shouldn't need override.
107105
106+
Additional methods can customize authentication.
107+
108108
.. versionadded:: 2.0
109109
"""
110110

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]:
112165
"""Get the authenticated user for a request
113166
114167
Must return a :class:`.jupyter_server.auth.User`,
115168
though it may be a subclass.
116169
117170
Return None if the request is not authenticated.
118-
"""
119171
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)
122202

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)
129203
return user
130204

131205
def identity_model(self, user: User) -> dict:
@@ -138,4 +212,190 @@ def get_handlers(self) -> list:
138212
139213
For example, an OAuth callback handler.
140214
"""
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)

jupyter_server/base/handlers.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -185,12 +185,19 @@ def logged_in(self):
185185
@property
186186
def login_handler(self):
187187
"""Return the login handler for this application, if any."""
188-
return self.settings.get("login_handler_class", None)
188+
warnings.warn(
189+
"""JupyterHandler.login_handler is deprecated in 2.0,
190+
use JupyterHandler.identity_provider.
191+
""",
192+
DeprecationWarning,
193+
stacklevel=2,
194+
)
195+
return self.identity_provider.login_handler_class
189196

190197
@property
191198
def token(self):
192199
"""Return the login token for this application, if any."""
193-
return self.settings.get("token", None)
200+
return self.identity_provider.token
194201

195202
@property
196203
def login_available(self):
@@ -200,9 +207,7 @@ def login_available(self):
200207
whether the user is already logged in or not.
201208
202209
"""
203-
if self.login_handler is None:
204-
return False
205-
return bool(self.login_handler.get_login_available(self.settings))
210+
return self.identity_provider.login_available
206211

207212
@property
208213
def authorizer(self):
@@ -625,7 +630,8 @@ def template_namespace(self):
625630
ws_url=self.ws_url,
626631
logged_in=self.logged_in,
627632
allow_password_change=self.settings.get("allow_password_change"),
628-
login_available=self.login_available,
633+
auth_enabled=self.identity_provider.login_available,
634+
login_available=self.identity_provider.login_available,
629635
token_available=bool(self.token),
630636
static_url=self.static_url,
631637
sys_info=json_sys_info(),

jupyter_server/pytest_plugin.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@ def jp_web_app(jp_serverapp):
322322
@pytest.fixture
323323
def jp_auth_header(jp_serverapp):
324324
"""Configures an authorization header using the token from the serverapp fixture."""
325-
return {"Authorization": f"token {jp_serverapp.token}"}
325+
return {"Authorization": f"token {jp_serverapp.identity_provider.token}"}
326326

327327

328328
@pytest.fixture

0 commit comments

Comments
 (0)