Skip to content

Commit d94fc76

Browse files
committed
test coverage for legacy login
1 parent 67773ce commit d94fc76

File tree

5 files changed

+198
-15
lines changed

5 files changed

+198
-15
lines changed

jupyter_server/auth/identity.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ def _token_default(self):
175175

176176
need_token = Bool(True)
177177

178-
async def get_user(self, handler: JupyterHandler) -> User | None:
178+
def get_user(self, handler: JupyterHandler) -> User | None | Awaitable[User | None]:
179179
"""Get the authenticated user for a request
180180
181181
Must return a :class:`.jupyter_server.auth.User`,
@@ -185,6 +185,12 @@ async def get_user(self, handler: JupyterHandler) -> User | None:
185185
186186
_may_ be a coroutine
187187
"""
188+
return self._get_user(handler)
189+
190+
# not sure how to have optional-async type signature
191+
# on base class with `async def` without splitting it into two methods
192+
193+
async def _get_user(self, handler: JupyterHandler) -> User | None:
188194
if getattr(handler, "_jupyter_current_user", None):
189195
# already authenticated
190196
return handler._jupyter_current_user
@@ -200,11 +206,11 @@ async def get_user(self, handler: JupyterHandler) -> User | None:
200206
# because token is always explicit
201207
user = token_user or cookie_user
202208

203-
if token_user:
209+
if user is not None and token_user is not None:
204210
# if token-authenticated, persist user_id in cookie
205211
# if it hasn't already been stored there
206212
if user != cookie_user:
207-
self.set_login_cookie(handler, cast(User, user))
213+
self.set_login_cookie(handler, user)
208214
# Record that the current request has been authenticated with a token.
209215
# Used in is_token_authenticated above.
210216
handler._token_authenticated = True
@@ -522,7 +528,7 @@ def process_login_form(self, handler: JupyterHandler) -> User | None:
522528
if new_password and self.allow_password_change:
523529
config_dir = handler.settings.get("config_dir", "")
524530
config_file = os.path.join(config_dir, "jupyter_server_config.json")
525-
set_password(new_password, config_file=config_file)
531+
self.hashed_password = set_password(new_password, config_file=config_file)
526532
self.log.info(_i18n(f"Wrote hashed password to {config_file}"))
527533

528534
return user
@@ -552,6 +558,13 @@ class LegacyIdentityProvider(PasswordIdentityProvider):
552558
# settings must be passed for
553559
settings = Dict()
554560

561+
@default("settings")
562+
def _default_settings(self):
563+
return {
564+
"token": self.token,
565+
"password": self.hashed_password,
566+
}
567+
555568
@default("login_handler_class")
556569
def _default_login_handler_class(self):
557570
from .login import LegacyLoginHandler
@@ -562,12 +575,15 @@ def _default_login_handler_class(self):
562575
def auth_enabled(self):
563576
return self.login_available
564577

565-
def get_user(self, handler):
566-
return _backward_compat_user(self.login_handler_class.get_user(handler))
578+
def get_user(self, handler: JupyterHandler) -> User | None:
579+
user = self.login_handler_class.get_user(handler)
580+
if user is None:
581+
return None
582+
return _backward_compat_user(user)
567583

568584
@property
569585
def login_available(self):
570-
return self.login_handler_class.login_available(self.settings)
586+
return self.login_handler_class.get_login_available(self.settings)
571587

572588
def should_check_origin(self, handler: JupyterHandler) -> bool:
573589
return self.login_handler_class.should_check_origin(handler)

jupyter_server/auth/login.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,9 @@ def post(self):
106106
if new_password and self.settings.get("allow_password_change"):
107107
config_dir = self.settings.get("config_dir", "")
108108
config_file = os.path.join(config_dir, "jupyter_server_config.json")
109-
set_password(new_password, config_file=config_file)
109+
self.identity_provider.hashed_password = self.settings[
110+
"password"
111+
] = set_password(new_password, config_file=config_file)
110112
self.log.info("Wrote hashed password to %s" % config_file)
111113
else:
112114
self.set_status(401)

jupyter_server/auth/security.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,4 +169,5 @@ def set_password(password=None, config_file=None):
169169
hashed_password = passwd(password)
170170

171171
with persist_config(config_file) as config:
172-
config.ServerApp.password = hashed_password
172+
config.IdentityProvider.hashed_password = hashed_password
173+
return hashed_password

jupyter_server/serverapp.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1104,15 +1104,21 @@ def _default_min_open_files_limit(self):
11041104
def _warn_deprecated_config(self, change, clsname, new_name=None):
11051105
if new_name is None:
11061106
new_name = change.name
1107-
if (
1108-
clsname not in self.config
1109-
or change.name not in self.config[clsname]
1110-
or self.config[clsname][change.name] != change.new
1111-
):
1107+
if clsname not in self.config or new_name not in self.config[clsname]:
1108+
# Deprecated config used, new config not used.
1109+
# Use deprecated config, warn about new name.
11121110
self.log.warning(
11131111
f"ServerApp.{change.name} config is deprecated in 2.0. Use {clsname}.{new_name}."
11141112
)
1115-
self.config[clsname][change.name] = change.new
1113+
self.config[clsname][new_name] = change.new
1114+
else:
1115+
# Deprecated config used, new config also used.
1116+
# Warn only if the values differ.
1117+
# If the values are the same, assume intentional backward-compatible config.
1118+
if self.config[clsname][new_name] != change.new:
1119+
self.log.warning(
1120+
f"Ignoring deprecated ServerApp.{change.name} config. Using {clsname}.{new_name}."
1121+
)
11161122

11171123
@observe("password")
11181124
def _deprecated_password(self, change):
@@ -1873,6 +1879,7 @@ def init_configurables(self):
18731879
if self.identity_provider_class is LegacyIdentityProvider:
18741880
# legacy config stored the password in tornado_settings
18751881
self.tornado_settings["password"] = self.identity_provider.hashed_password
1882+
self.tornado_settings["token"] = self.identity_provider.token
18761883

18771884
if self._token_set:
18781885
self.log.warning(

tests/auth/test_legacy_login.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""
2+
Test legacy login config via ServerApp.login_handler_class
3+
"""
4+
5+
import json
6+
from functools import wraps
7+
from urllib.parse import urlencode
8+
9+
import pytest
10+
from tornado.httpclient import HTTPClientError
11+
from tornado.httputil import parse_cookie, url_concat
12+
from traitlets.config import Config
13+
14+
from jupyter_server.auth.identity import LegacyIdentityProvider
15+
from jupyter_server.auth.login import LoginHandler
16+
from jupyter_server.auth.security import passwd
17+
from jupyter_server.serverapp import ServerApp
18+
from jupyter_server.utils import url_path_join
19+
20+
# Don't raise on deprecation warnings in this module testing deprecated behavior
21+
pytestmark = pytest.mark.filterwarnings("ignore::DeprecationWarning")
22+
23+
24+
def record_calls(f):
25+
"""Decorator to record call history"""
26+
f._calls = []
27+
28+
@wraps(f)
29+
def wrapped_f(*args, **kwargs):
30+
f._calls.append((args, kwargs))
31+
return f(*args, **kwargs)
32+
33+
return wrapped_f
34+
35+
36+
class CustomLoginHandler(LoginHandler):
37+
@classmethod
38+
@record_calls
39+
def get_user(cls, handler):
40+
header_user = handler.request.headers.get("test-user")
41+
if header_user:
42+
if header_user == "super":
43+
return super().get_user(handler)
44+
return header_user
45+
else:
46+
return None
47+
48+
49+
@pytest.fixture
50+
def jp_server_config():
51+
cfg = Config()
52+
cfg.ServerApp.login_handler_class = CustomLoginHandler
53+
return cfg
54+
55+
56+
def test_legacy_identity_config(jp_serverapp):
57+
# setting login_handler_class sets LegacyIdentityProvider
58+
app = ServerApp()
59+
idp = jp_serverapp.identity_provider
60+
assert type(idp) is LegacyIdentityProvider
61+
assert idp.login_available
62+
assert idp.auth_enabled
63+
assert idp.token
64+
assert idp.get_handlers() == [
65+
("/login", idp.login_handler_class),
66+
("/logout", idp.logout_handler_class),
67+
]
68+
69+
70+
async def test_legacy_identity_api(jp_serverapp, jp_fetch):
71+
response = await jp_fetch("/api/me", headers={"test-user": "pinecone"})
72+
assert response.code == 200
73+
model = json.loads(response.body.decode("utf8"))
74+
assert model["identity"]["username"] == "pinecone"
75+
76+
77+
async def test_legacy_base_class(jp_serverapp, jp_fetch):
78+
response = await jp_fetch("/api/me", headers={"test-user": "super"})
79+
assert "Set-Cookie" in response.headers
80+
cookie = response.headers["Set-Cookie"]
81+
assert response.code == 200
82+
model = json.loads(response.body.decode("utf8"))
83+
user_id = model["identity"]["username"] # a random uuid
84+
assert user_id
85+
86+
response = await jp_fetch("/api/me", headers={"test-user": "super", "Cookie": cookie})
87+
model2 = json.loads(response.body.decode("utf8"))
88+
# second request, should trigger cookie auth
89+
assert model2["identity"] == model["identity"]
90+
91+
92+
async def test_legacy_login(jp_serverapp, http_server_client, jp_base_url, jp_fetch):
93+
login_url = url_path_join(jp_base_url, "login")
94+
first = await http_server_client.fetch(login_url)
95+
cookie_header = first.headers["Set-Cookie"]
96+
xsrf = parse_cookie(cookie_header).get("_xsrf", "")
97+
new_password = "super-secret"
98+
99+
async def login(form_fields):
100+
form = {"_xsrf": xsrf}
101+
form.update(form_fields)
102+
try:
103+
resp = await http_server_client.fetch(
104+
login_url,
105+
method="POST",
106+
body=urlencode(form),
107+
headers={"Cookie": cookie_header},
108+
follow_redirects=False,
109+
)
110+
except HTTPClientError as e:
111+
resp = e.response
112+
assert resp.code == 302, "Should have returned a redirect!"
113+
return resp
114+
115+
resp = await login(
116+
dict(password=jp_serverapp.identity_provider.token, new_password=new_password)
117+
)
118+
cookie = resp.headers["Set-Cookie"]
119+
id_resp = await jp_fetch("/api/me", headers={"test-user": "super", "Cookie": cookie})
120+
assert id_resp.code == 200
121+
model = json.loads(id_resp.body.decode("utf8"))
122+
user_id = model["identity"]["username"] # a random uuid
123+
124+
# verify password change with second login
125+
resp2 = await login(dict(password=new_password))
126+
cookie = resp.headers["Set-Cookie"]
127+
id_resp = await jp_fetch("/api/me", headers={"test-user": "super", "Cookie": cookie})
128+
assert id_resp.code == 200
129+
model = json.loads(id_resp.body.decode("utf8"))
130+
user_id2 = model["identity"]["username"] # a random uuid
131+
assert user_id2 == user_id
132+
133+
134+
def test_deprecated_config():
135+
cfg = Config()
136+
cfg.ServerApp.token = token = "asdf"
137+
cfg.ServerApp.password = password = passwd("secrets")
138+
app = ServerApp(config=cfg)
139+
app.initialize([])
140+
app.init_configurables()
141+
assert app.identity_provider.token == token
142+
assert app.token == token
143+
assert app.identity_provider.hashed_password == password
144+
assert app.password == password
145+
146+
147+
def test_deprecated_config_priority():
148+
cfg = Config()
149+
cfg.ServerApp.token = "ignored"
150+
cfg.IdentityProvider.token = token = "idp_token"
151+
cfg.ServerApp.password = passwd("ignored")
152+
cfg.PasswordIdentityProvider.hashed_password = password = passwd("used")
153+
app = ServerApp(config=cfg)
154+
app.initialize([])
155+
app.init_configurables()
156+
assert app.identity_provider.token == token
157+
assert app.identity_provider.hashed_password == password

0 commit comments

Comments
 (0)