mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-18 15:33:02 +00:00
Deprecate and remove some old auth bits
- remove long-deprecated `POST /api/authorizations/token` for creating tokens - deprecate but do not remove `GET /api/authorizations/token/:token` in favor of GET /api/user - remove shared-cookie auth for services from HubAuth, rely on OAuth for browser-auth instead - use `/hub/api/user` to resolve user instead of `/authorizations/token` which is now deprecated
This commit is contained in:
@@ -203,8 +203,6 @@ To use HubAuth, you must set the `.api_token`, either programmatically when cons
|
|||||||
or via the `JUPYTERHUB_API_TOKEN` environment variable.
|
or via the `JUPYTERHUB_API_TOKEN` environment variable.
|
||||||
|
|
||||||
Most of the logic for authentication implementation is found in the
|
Most of the logic for authentication implementation is found in the
|
||||||
[`HubAuth.user_for_cookie`][hubauth.user_for_cookie]
|
|
||||||
and in the
|
|
||||||
[`HubAuth.user_for_token`][hubauth.user_for_token]
|
[`HubAuth.user_for_token`][hubauth.user_for_token]
|
||||||
methods, which makes a request of the Hub, and returns:
|
methods, which makes a request of the Hub, and returns:
|
||||||
|
|
||||||
@@ -241,11 +239,11 @@ from urllib.parse import quote
|
|||||||
|
|
||||||
from flask import Flask, redirect, request, Response
|
from flask import Flask, redirect, request, Response
|
||||||
|
|
||||||
from jupyterhub.services.auth import HubAuth
|
from jupyterhub.services.auth import HubOAuth
|
||||||
|
|
||||||
prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
|
prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
|
||||||
|
|
||||||
auth = HubAuth(
|
auth = HubOAuth(
|
||||||
api_token=os.environ['JUPYTERHUB_API_TOKEN'],
|
api_token=os.environ['JUPYTERHUB_API_TOKEN'],
|
||||||
cache_max_age=60,
|
cache_max_age=60,
|
||||||
)
|
)
|
||||||
@@ -257,11 +255,8 @@ def authenticated(f):
|
|||||||
"""Decorator for authenticating with the Hub"""
|
"""Decorator for authenticating with the Hub"""
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated(*args, **kwargs):
|
def decorated(*args, **kwargs):
|
||||||
cookie = request.cookies.get(auth.cookie_name)
|
|
||||||
token = request.headers.get(auth.auth_header_name)
|
token = request.headers.get(auth.auth_header_name)
|
||||||
if cookie:
|
if token:
|
||||||
user = auth.user_for_cookie(cookie)
|
|
||||||
elif token:
|
|
||||||
user = auth.user_for_token(token)
|
user = auth.user_for_token(token)
|
||||||
else:
|
else:
|
||||||
user = None
|
user = None
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
# Authenticating a flask service with JupyterHub
|
# Authenticating a flask service with JupyterHub
|
||||||
|
|
||||||
Uses `jupyterhub.services.HubAuth` to authenticate requests with the Hub in a [flask][] application.
|
Uses `jupyterhub.services.HubOAuth` to authenticate requests with the Hub in a [flask][] application.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ Uses `jupyterhub.services.HubAuth` to authenticate requests with the Hub in a [f
|
|||||||
|
|
||||||
jupyterhub --ip=127.0.0.1
|
jupyterhub --ip=127.0.0.1
|
||||||
|
|
||||||
2. Visit http://127.0.0.1:8000/services/whoami/ or http://127.0.0.1:8000/services/whoami-oauth/
|
2. Visit http://127.0.0.1:8000/services/whoami/
|
||||||
|
|
||||||
After logging in with your local-system credentials, you should see a JSON dump of your user info:
|
After logging in with your local-system credentials, you should see a JSON dump of your user info:
|
||||||
|
|
||||||
|
@@ -5,10 +5,12 @@ c.JupyterHub.services = [
|
|||||||
'command': ['flask', 'run', '--port=10101'],
|
'command': ['flask', 'run', '--port=10101'],
|
||||||
'environment': {'FLASK_APP': 'whoami-flask.py'},
|
'environment': {'FLASK_APP': 'whoami-flask.py'},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
'name': 'whoami-oauth',
|
|
||||||
'url': 'http://127.0.0.1:10201',
|
|
||||||
'command': ['flask', 'run', '--port=10201'],
|
|
||||||
'environment': {'FLASK_APP': 'whoami-oauth.py'},
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# dummy auth and simple spawner for testing
|
||||||
|
# any username and password will work
|
||||||
|
c.JupyterHub.spawner_class = 'simple'
|
||||||
|
c.JupyterHub.authenticator_class = 'dummy'
|
||||||
|
|
||||||
|
# listen only on localhost while testing with wide-open auth
|
||||||
|
c.JupyterHub.ip = '127.0.0.1'
|
||||||
|
@@ -4,42 +4,48 @@ whoami service authentication with the Hub
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
from flask import make_response
|
||||||
from flask import redirect
|
from flask import redirect
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask import Response
|
from flask import Response
|
||||||
|
from flask import session
|
||||||
|
|
||||||
from jupyterhub.services.auth import HubAuth
|
from jupyterhub.services.auth import HubOAuth
|
||||||
|
|
||||||
|
|
||||||
prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
|
prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
|
||||||
|
|
||||||
auth = HubAuth(api_token=os.environ['JUPYTERHUB_API_TOKEN'], cache_max_age=60)
|
auth = HubOAuth(api_token=os.environ['JUPYTERHUB_API_TOKEN'], cache_max_age=60)
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
# encryption key for session cookies
|
||||||
|
app.secret_key = secrets.token_bytes(32)
|
||||||
|
|
||||||
|
|
||||||
def authenticated(f):
|
def authenticated(f):
|
||||||
"""Decorator for authenticating with the Hub"""
|
"""Decorator for authenticating with the Hub via OAuth"""
|
||||||
|
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated(*args, **kwargs):
|
def decorated(*args, **kwargs):
|
||||||
cookie = request.cookies.get(auth.cookie_name)
|
token = session.get("token")
|
||||||
token = request.headers.get(auth.auth_header_name)
|
|
||||||
if cookie:
|
if token:
|
||||||
user = auth.user_for_cookie(cookie)
|
|
||||||
elif token:
|
|
||||||
user = auth.user_for_token(token)
|
user = auth.user_for_token(token)
|
||||||
else:
|
else:
|
||||||
user = None
|
user = None
|
||||||
|
|
||||||
if user:
|
if user:
|
||||||
return f(user, *args, **kwargs)
|
return f(user, *args, **kwargs)
|
||||||
else:
|
else:
|
||||||
# redirect to login url on failed auth
|
# redirect to login url on failed auth
|
||||||
return redirect(auth.login_url + '?next=%s' % quote(request.path))
|
state = auth.generate_state(next_url=request.path)
|
||||||
|
response = make_response(redirect(auth.login_url + '&state=%s' % state))
|
||||||
|
response.set_cookie(auth.state_cookie_name, state)
|
||||||
|
return response
|
||||||
|
|
||||||
return decorated
|
return decorated
|
||||||
|
|
||||||
@@ -50,3 +56,24 @@ def whoami(user):
|
|||||||
return Response(
|
return Response(
|
||||||
json.dumps(user, indent=1, sort_keys=True), mimetype='application/json'
|
json.dumps(user, indent=1, sort_keys=True), mimetype='application/json'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route(prefix + 'oauth_callback')
|
||||||
|
def oauth_callback():
|
||||||
|
code = request.args.get('code', None)
|
||||||
|
if code is None:
|
||||||
|
return 403
|
||||||
|
|
||||||
|
# validate state field
|
||||||
|
arg_state = request.args.get('state', None)
|
||||||
|
cookie_state = request.cookies.get(auth.state_cookie_name)
|
||||||
|
if arg_state is None or arg_state != cookie_state:
|
||||||
|
# state doesn't match
|
||||||
|
return 403
|
||||||
|
|
||||||
|
token = auth.token_for_code(code)
|
||||||
|
# store token in session cookie
|
||||||
|
session["token"] = token
|
||||||
|
next_url = auth.get_next_url(cookie_state) or prefix
|
||||||
|
response = make_response(redirect(next_url))
|
||||||
|
return response
|
||||||
|
@@ -1,72 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
whoami service authentication with the Hub
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from functools import wraps
|
|
||||||
|
|
||||||
from flask import Flask
|
|
||||||
from flask import make_response
|
|
||||||
from flask import redirect
|
|
||||||
from flask import request
|
|
||||||
from flask import Response
|
|
||||||
|
|
||||||
from jupyterhub.services.auth import HubOAuth
|
|
||||||
|
|
||||||
|
|
||||||
prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
|
|
||||||
|
|
||||||
auth = HubOAuth(api_token=os.environ['JUPYTERHUB_API_TOKEN'], cache_max_age=60)
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def authenticated(f):
|
|
||||||
"""Decorator for authenticating with the Hub via OAuth"""
|
|
||||||
|
|
||||||
@wraps(f)
|
|
||||||
def decorated(*args, **kwargs):
|
|
||||||
token = request.cookies.get(auth.cookie_name)
|
|
||||||
if token:
|
|
||||||
user = auth.user_for_token(token)
|
|
||||||
else:
|
|
||||||
user = None
|
|
||||||
if user:
|
|
||||||
return f(user, *args, **kwargs)
|
|
||||||
else:
|
|
||||||
# redirect to login url on failed auth
|
|
||||||
state = auth.generate_state(next_url=request.path)
|
|
||||||
response = make_response(redirect(auth.login_url + '&state=%s' % state))
|
|
||||||
response.set_cookie(auth.state_cookie_name, state)
|
|
||||||
return response
|
|
||||||
|
|
||||||
return decorated
|
|
||||||
|
|
||||||
|
|
||||||
@app.route(prefix)
|
|
||||||
@authenticated
|
|
||||||
def whoami(user):
|
|
||||||
return Response(
|
|
||||||
json.dumps(user, indent=1, sort_keys=True), mimetype='application/json'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route(prefix + 'oauth_callback')
|
|
||||||
def oauth_callback():
|
|
||||||
code = request.args.get('code', None)
|
|
||||||
if code is None:
|
|
||||||
return 403
|
|
||||||
|
|
||||||
# validate state field
|
|
||||||
arg_state = request.args.get('state', None)
|
|
||||||
cookie_state = request.cookies.get(auth.state_cookie_name)
|
|
||||||
if arg_state is None or arg_state != cookie_state:
|
|
||||||
# state doesn't match
|
|
||||||
return 403
|
|
||||||
|
|
||||||
token = auth.token_for_code(code)
|
|
||||||
next_url = auth.get_next_url(cookie_state) or prefix
|
|
||||||
response = make_response(redirect(next_url))
|
|
||||||
response.set_cookie(auth.cookie_name, token)
|
|
||||||
return response
|
|
@@ -14,7 +14,6 @@ from tornado import web
|
|||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from .. import scopes
|
from .. import scopes
|
||||||
from ..user import User
|
|
||||||
from ..utils import token_authenticated
|
from ..utils import token_authenticated
|
||||||
from .base import APIHandler
|
from .base import APIHandler
|
||||||
from .base import BaseHandler
|
from .base import BaseHandler
|
||||||
@@ -24,7 +23,7 @@ class TokenAPIHandler(APIHandler):
|
|||||||
@token_authenticated
|
@token_authenticated
|
||||||
def get(self, token):
|
def get(self, token):
|
||||||
# FIXME: deprecate this API for oauth token resolution, in favor of using /api/user
|
# FIXME: deprecate this API for oauth token resolution, in favor of using /api/user
|
||||||
# TODO: require specific scope for this deprecated API, applied to oauth client secrets only?
|
# TODO: require specific scope for this deprecated API, applied to service tokens only?
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
"/authorizations/token/:token endpoint is deprecated in JupyterHub 2.0. Use /api/user"
|
"/authorizations/token/:token endpoint is deprecated in JupyterHub 2.0. Use /api/user"
|
||||||
)
|
)
|
||||||
@@ -55,53 +54,20 @@ class TokenAPIHandler(APIHandler):
|
|||||||
self.write(json.dumps(model))
|
self.write(json.dumps(model))
|
||||||
|
|
||||||
async def post(self):
|
async def post(self):
|
||||||
warn_msg = (
|
|
||||||
"Using deprecated token creation endpoint %s."
|
|
||||||
" Use /hub/api/users/:user/tokens instead."
|
|
||||||
) % self.request.uri
|
|
||||||
self.log.warning(warn_msg)
|
|
||||||
requester = user = self.current_user
|
|
||||||
if user is None:
|
|
||||||
# allow requesting a token with username and password
|
|
||||||
# for authenticators where that's possible
|
|
||||||
data = self.get_json_body()
|
|
||||||
try:
|
|
||||||
requester = user = await self.login_user(data)
|
|
||||||
except Exception as e:
|
|
||||||
self.log.error("Failure trying to authenticate with form data: %s" % e)
|
|
||||||
user = None
|
|
||||||
if user is None:
|
|
||||||
raise web.HTTPError(403)
|
|
||||||
else:
|
|
||||||
data = self.get_json_body()
|
|
||||||
# admin users can request tokens for other users
|
|
||||||
if data and data.get('username'):
|
|
||||||
user = self.find_user(data['username'])
|
|
||||||
if user is not requester and not requester.admin:
|
|
||||||
raise web.HTTPError(
|
raise web.HTTPError(
|
||||||
403, "Only admins can request tokens for other users."
|
404,
|
||||||
)
|
"Deprecated endpoint /hub/api/authorizations/token is removed in JupyterHub 2.0."
|
||||||
if requester.admin and user is None:
|
" Use /hub/api/users/:user/tokens instead.",
|
||||||
raise web.HTTPError(400, "No such user '%s'" % data['username'])
|
|
||||||
|
|
||||||
note = (data or {}).get('note')
|
|
||||||
if not note:
|
|
||||||
note = "Requested via deprecated api"
|
|
||||||
if requester is not user:
|
|
||||||
kind = 'user' if isinstance(user, User) else 'service'
|
|
||||||
note += " by %s %s" % (kind, requester.name)
|
|
||||||
|
|
||||||
api_token = user.new_api_token(note=note)
|
|
||||||
self.write(
|
|
||||||
json.dumps(
|
|
||||||
{'token': api_token, 'warning': warn_msg, 'user': self.user_model(user)}
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class CookieAPIHandler(APIHandler):
|
class CookieAPIHandler(APIHandler):
|
||||||
@token_authenticated
|
@token_authenticated
|
||||||
def get(self, cookie_name, cookie_value=None):
|
def get(self, cookie_name, cookie_value=None):
|
||||||
|
self.log.warning(
|
||||||
|
"/authorizations/cookie endpoint is deprecated in JupyterHub 2.0. Use /api/user with OAuth tokens."
|
||||||
|
)
|
||||||
|
|
||||||
cookie_name = quote(cookie_name, safe='')
|
cookie_name = quote(cookie_name, safe='')
|
||||||
if cookie_value is None:
|
if cookie_value is None:
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
|
@@ -44,6 +44,11 @@ class SelfAPIHandler(APIHandler):
|
|||||||
self.raw_scopes.update(scopes.identify_scopes(user.orm_user))
|
self.raw_scopes.update(scopes.identify_scopes(user.orm_user))
|
||||||
self.parsed_scopes = scopes.parse_scopes(self.raw_scopes)
|
self.parsed_scopes = scopes.parse_scopes(self.raw_scopes)
|
||||||
model = self.user_model(user)
|
model = self.user_model(user)
|
||||||
|
# validate return, should have at least kind and name,
|
||||||
|
# otherwise our filters did something wrong
|
||||||
|
for key in ("kind", "name"):
|
||||||
|
if key not in model:
|
||||||
|
raise ValueError(f"Missing identify model for {user}: {model}")
|
||||||
self.write(json.dumps(model))
|
self.write(json.dumps(model))
|
||||||
|
|
||||||
|
|
||||||
|
@@ -269,6 +269,9 @@ def identify_scopes(obj):
|
|||||||
for field in {"name", "admin", "groups"}
|
for field in {"name", "admin", "groups"}
|
||||||
}
|
}
|
||||||
elif isinstance(obj, orm.Service):
|
elif isinstance(obj, orm.Service):
|
||||||
|
# FIXME: need sub-scopes for services
|
||||||
|
# until then, we have just one service scope:
|
||||||
|
return {f"read:services!service={obj.name}"}
|
||||||
return {
|
return {
|
||||||
f"read:services:{field}!service={obj.name}" for field in {"name", "admin"}
|
f"read:services:{field}!service={obj.name}" for field in {"name", "admin"}
|
||||||
}
|
}
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
"""Authenticating services with JupyterHub.
|
"""Authenticating services with JupyterHub.
|
||||||
|
|
||||||
Cookies are sent to the Hub for verification. The Hub replies with a JSON
|
Tokens are sent to the Hub for verification.
|
||||||
model describing the authenticated user.
|
The Hub replies with a JSON model describing the authenticated user.
|
||||||
|
|
||||||
``HubAuth`` can be used in any application, even outside tornado.
|
``HubAuth`` can be used in any application, even outside tornado.
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ authenticate with the Hub.
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
@@ -20,7 +21,6 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
import warnings
|
import warnings
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from urllib.parse import quote
|
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -113,9 +113,15 @@ class HubAuth(SingletonConfigurable):
|
|||||||
|
|
||||||
This can be used by any application.
|
This can be used by any application.
|
||||||
|
|
||||||
|
Use this base class only for direct, token-authenticated applications
|
||||||
|
(web APIs).
|
||||||
|
For applications that support direct visits from browsers,
|
||||||
|
use HubOAuth to enable OAuth redirect-based authentication.
|
||||||
|
|
||||||
|
|
||||||
If using tornado, use via :class:`HubAuthenticated` mixin.
|
If using tornado, use via :class:`HubAuthenticated` mixin.
|
||||||
If using manually, use the ``.user_for_cookie(cookie_value)`` method
|
If using manually, use the ``.user_for_token(token_value)`` method
|
||||||
to identify the user corresponding to a given cookie value.
|
to identify the user owning a given token.
|
||||||
|
|
||||||
The following config must be set:
|
The following config must be set:
|
||||||
|
|
||||||
@@ -129,9 +135,6 @@ class HubAuth(SingletonConfigurable):
|
|||||||
- cookie_cache_max_age: the number of seconds responses
|
- cookie_cache_max_age: the number of seconds responses
|
||||||
from the Hub should be cached.
|
from the Hub should be cached.
|
||||||
- login_url (the *public* ``/hub/login`` URL of the Hub).
|
- login_url (the *public* ``/hub/login`` URL of the Hub).
|
||||||
- cookie_name: the name of the cookie I should be using,
|
|
||||||
if different from the default (unlikely).
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
hub_host = Unicode(
|
hub_host = Unicode(
|
||||||
@@ -239,10 +242,6 @@ class HubAuth(SingletonConfigurable):
|
|||||||
""",
|
""",
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
cookie_name = Unicode(
|
|
||||||
'jupyterhub-services', help="""The name of the cookie I should be looking for"""
|
|
||||||
).tag(config=True)
|
|
||||||
|
|
||||||
cookie_options = Dict(
|
cookie_options = Dict(
|
||||||
help="""Additional options to pass when setting cookies.
|
help="""Additional options to pass when setting cookies.
|
||||||
|
|
||||||
@@ -286,12 +285,12 @@ class HubAuth(SingletonConfigurable):
|
|||||||
def _default_cache(self):
|
def _default_cache(self):
|
||||||
return _ExpiringDict(self.cache_max_age)
|
return _ExpiringDict(self.cache_max_age)
|
||||||
|
|
||||||
def _check_hub_authorization(self, url, cache_key=None, use_cache=True):
|
def _check_hub_authorization(self, url, api_token, cache_key=None, use_cache=True):
|
||||||
"""Identify a user with the Hub
|
"""Identify a user with the Hub
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
url (str): The API URL to check the Hub for authorization
|
url (str): The API URL to check the Hub for authorization
|
||||||
(e.g. http://127.0.0.1:8081/hub/api/authorizations/token/abc-def)
|
(e.g. http://127.0.0.1:8081/hub/api/user)
|
||||||
cache_key (str): The key for checking the cache
|
cache_key (str): The key for checking the cache
|
||||||
use_cache (bool): Specify use_cache=False to skip cached cookie values (default: True)
|
use_cache (bool): Specify use_cache=False to skip cached cookie values (default: True)
|
||||||
|
|
||||||
@@ -309,7 +308,12 @@ class HubAuth(SingletonConfigurable):
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
app_log.debug("HubAuth cache miss: %s", cache_key)
|
app_log.debug("HubAuth cache miss: %s", cache_key)
|
||||||
|
|
||||||
data = self._api_request('GET', url, allow_404=True)
|
data = self._api_request(
|
||||||
|
'GET',
|
||||||
|
url,
|
||||||
|
headers={"Authorization": "token " + api_token},
|
||||||
|
allow_403=True,
|
||||||
|
)
|
||||||
if data is None:
|
if data is None:
|
||||||
app_log.warning("No Hub user identified for request")
|
app_log.warning("No Hub user identified for request")
|
||||||
else:
|
else:
|
||||||
@@ -321,7 +325,7 @@ class HubAuth(SingletonConfigurable):
|
|||||||
|
|
||||||
def _api_request(self, method, url, **kwargs):
|
def _api_request(self, method, url, **kwargs):
|
||||||
"""Make an API request"""
|
"""Make an API request"""
|
||||||
allow_404 = kwargs.pop('allow_404', False)
|
allow_403 = kwargs.pop('allow_403', False)
|
||||||
headers = kwargs.setdefault('headers', {})
|
headers = kwargs.setdefault('headers', {})
|
||||||
headers.setdefault('Authorization', 'token %s' % self.api_token)
|
headers.setdefault('Authorization', 'token %s' % self.api_token)
|
||||||
if "cert" not in kwargs and self.certfile and self.keyfile:
|
if "cert" not in kwargs and self.certfile and self.keyfile:
|
||||||
@@ -345,7 +349,7 @@ class HubAuth(SingletonConfigurable):
|
|||||||
raise HTTPError(500, msg)
|
raise HTTPError(500, msg)
|
||||||
|
|
||||||
data = None
|
data = None
|
||||||
if r.status_code == 404 and allow_404:
|
if r.status_code == 403 and allow_403:
|
||||||
pass
|
pass
|
||||||
elif r.status_code == 403:
|
elif r.status_code == 403:
|
||||||
app_log.error(
|
app_log.error(
|
||||||
@@ -389,26 +393,9 @@ class HubAuth(SingletonConfigurable):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
def user_for_cookie(self, encrypted_cookie, use_cache=True, session_id=''):
|
def user_for_cookie(self, encrypted_cookie, use_cache=True, session_id=''):
|
||||||
"""Ask the Hub to identify the user for a given cookie.
|
"""Deprecated and removed. Use HubOAuth to authenticate browsers."""
|
||||||
|
raise RuntimeError(
|
||||||
Args:
|
"Identifying users by shared cookie is removed in JupyterHub 2.0. Use OAuth tokens."
|
||||||
encrypted_cookie (str): the cookie value (not decrypted, the Hub will do that)
|
|
||||||
use_cache (bool): Specify use_cache=False to skip cached cookie values (default: True)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
user_model (dict): The user model, if a user is identified, None if authentication fails.
|
|
||||||
|
|
||||||
The 'name' field contains the user's name.
|
|
||||||
"""
|
|
||||||
return self._check_hub_authorization(
|
|
||||||
url=url_path_join(
|
|
||||||
self.api_url,
|
|
||||||
"authorizations/cookie",
|
|
||||||
self.cookie_name,
|
|
||||||
quote(encrypted_cookie, safe=''),
|
|
||||||
),
|
|
||||||
cache_key='cookie:{}:{}'.format(session_id, encrypted_cookie),
|
|
||||||
use_cache=use_cache,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def user_for_token(self, token, use_cache=True, session_id=''):
|
def user_for_token(self, token, use_cache=True, session_id=''):
|
||||||
@@ -425,14 +412,19 @@ class HubAuth(SingletonConfigurable):
|
|||||||
"""
|
"""
|
||||||
return self._check_hub_authorization(
|
return self._check_hub_authorization(
|
||||||
url=url_path_join(
|
url=url_path_join(
|
||||||
self.api_url, "authorizations/token", quote(token, safe='')
|
self.api_url,
|
||||||
|
"user",
|
||||||
|
),
|
||||||
|
api_token=token,
|
||||||
|
cache_key='token:{}:{}'.format(
|
||||||
|
session_id,
|
||||||
|
hashlib.sha256(token.encode("utf8", "replace")).hexdigest(),
|
||||||
),
|
),
|
||||||
cache_key='token:{}:{}'.format(session_id, token),
|
|
||||||
use_cache=use_cache,
|
use_cache=use_cache,
|
||||||
)
|
)
|
||||||
|
|
||||||
auth_header_name = 'Authorization'
|
auth_header_name = 'Authorization'
|
||||||
auth_header_pat = re.compile(r'token\s+(.+)', re.IGNORECASE)
|
auth_header_pat = re.compile(r'(?:token|bearer)\s+(.+)', re.IGNORECASE)
|
||||||
|
|
||||||
def get_token(self, handler):
|
def get_token(self, handler):
|
||||||
"""Get the user token from a request
|
"""Get the user token from a request
|
||||||
@@ -453,10 +445,8 @@ class HubAuth(SingletonConfigurable):
|
|||||||
|
|
||||||
def _get_user_cookie(self, handler):
|
def _get_user_cookie(self, handler):
|
||||||
"""Get the user model from a cookie"""
|
"""Get the user model from a cookie"""
|
||||||
encrypted_cookie = handler.get_cookie(self.cookie_name)
|
# overridden in HubOAuth to store the access token after oauth
|
||||||
session_id = self.get_session_id(handler)
|
return None
|
||||||
if encrypted_cookie:
|
|
||||||
return self.user_for_cookie(encrypted_cookie, session_id=session_id)
|
|
||||||
|
|
||||||
def get_session_id(self, handler):
|
def get_session_id(self, handler):
|
||||||
"""Get the jupyterhub session id
|
"""Get the jupyterhub session id
|
||||||
@@ -509,6 +499,9 @@ class HubAuth(SingletonConfigurable):
|
|||||||
class HubOAuth(HubAuth):
|
class HubOAuth(HubAuth):
|
||||||
"""HubAuth using OAuth for login instead of cookies set by the Hub.
|
"""HubAuth using OAuth for login instead of cookies set by the Hub.
|
||||||
|
|
||||||
|
Use this class if you want users to be able to visit your service with a browser.
|
||||||
|
They will be authenticated via OAuth with the Hub.
|
||||||
|
|
||||||
.. versionadded: 0.8
|
.. versionadded: 0.8
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@@ -141,7 +141,6 @@ class OAuthCallbackHandlerMixin(HubOAuthCallbackHandler):
|
|||||||
aliases = {
|
aliases = {
|
||||||
'user': 'SingleUserNotebookApp.user',
|
'user': 'SingleUserNotebookApp.user',
|
||||||
'group': 'SingleUserNotebookApp.group',
|
'group': 'SingleUserNotebookApp.group',
|
||||||
'cookie-name': 'HubAuth.cookie_name',
|
|
||||||
'hub-prefix': 'SingleUserNotebookApp.hub_prefix',
|
'hub-prefix': 'SingleUserNotebookApp.hub_prefix',
|
||||||
'hub-host': 'SingleUserNotebookApp.hub_host',
|
'hub-host': 'SingleUserNotebookApp.hub_host',
|
||||||
'hub-api-url': 'SingleUserNotebookApp.hub_api_url',
|
'hub-api-url': 'SingleUserNotebookApp.hub_api_url',
|
||||||
|
@@ -1176,76 +1176,13 @@ async def test_check_token(app):
|
|||||||
assert r.status_code == 404
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
@mark.parametrize("headers, status", [({}, 200), ({'Authorization': 'token bad'}, 403)])
|
@mark.parametrize("headers, status", [({}, 404), ({'Authorization': 'token bad'}, 404)])
|
||||||
async def test_get_new_token_deprecated(app, headers, status):
|
async def test_get_new_token_deprecated(app, headers, status):
|
||||||
# request a new token
|
# request a new token
|
||||||
r = await api_request(
|
r = await api_request(
|
||||||
app, 'authorizations', 'token', method='post', headers=headers
|
app, 'authorizations', 'token', method='post', headers=headers
|
||||||
)
|
)
|
||||||
assert r.status_code == status
|
assert r.status_code == status
|
||||||
if status != 200:
|
|
||||||
return
|
|
||||||
reply = r.json()
|
|
||||||
assert 'token' in reply
|
|
||||||
r = await api_request(app, 'authorizations', 'token', reply['token'])
|
|
||||||
r.raise_for_status()
|
|
||||||
reply = r.json()
|
|
||||||
assert reply['name'] == 'admin'
|
|
||||||
|
|
||||||
|
|
||||||
async def test_token_formdata_deprecated(app):
|
|
||||||
"""Create a token for a user with formdata and no auth header"""
|
|
||||||
data = {'username': 'fake', 'password': 'fake'}
|
|
||||||
r = await api_request(
|
|
||||||
app,
|
|
||||||
'authorizations',
|
|
||||||
'token',
|
|
||||||
method='post',
|
|
||||||
data=json.dumps(data) if data else None,
|
|
||||||
noauth=True,
|
|
||||||
)
|
|
||||||
assert r.status_code == 200
|
|
||||||
reply = r.json()
|
|
||||||
assert 'token' in reply
|
|
||||||
r = await api_request(app, 'authorizations', 'token', reply['token'])
|
|
||||||
r.raise_for_status()
|
|
||||||
reply = r.json()
|
|
||||||
assert reply['name'] == data['username']
|
|
||||||
|
|
||||||
|
|
||||||
@mark.parametrize(
|
|
||||||
"as_user, for_user, status",
|
|
||||||
[
|
|
||||||
('admin', 'other', 200),
|
|
||||||
('admin', 'missing', 400),
|
|
||||||
('user', 'other', 403),
|
|
||||||
('user', 'user', 200),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def test_token_as_user_deprecated(app, as_user, for_user, status):
|
|
||||||
# ensure both users exist
|
|
||||||
u = add_user(app.db, app, name=as_user)
|
|
||||||
if for_user != 'missing':
|
|
||||||
for_user_obj = add_user(app.db, app, name=for_user)
|
|
||||||
data = {'username': for_user}
|
|
||||||
headers = {'Authorization': 'token %s' % u.new_api_token()}
|
|
||||||
r = await api_request(
|
|
||||||
app,
|
|
||||||
'authorizations',
|
|
||||||
'token',
|
|
||||||
method='post',
|
|
||||||
data=json.dumps(data),
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert r.status_code == status
|
|
||||||
reply = r.json()
|
|
||||||
if status != 200:
|
|
||||||
return
|
|
||||||
assert 'token' in reply
|
|
||||||
r = await api_request(app, 'authorizations', 'token', reply['token'])
|
|
||||||
r.raise_for_status()
|
|
||||||
reply = r.json()
|
|
||||||
assert reply['name'] == data['username']
|
|
||||||
|
|
||||||
|
|
||||||
@mark.parametrize(
|
@mark.parametrize(
|
||||||
|
@@ -26,8 +26,8 @@ from tornado.web import RequestHandler
|
|||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from ..services.auth import _ExpiringDict
|
from ..services.auth import _ExpiringDict
|
||||||
from ..services.auth import HubAuth
|
from ..services.auth import HubOAuth
|
||||||
from ..services.auth import HubAuthenticated
|
from ..services.auth import HubOAuthenticated
|
||||||
from ..utils import url_path_join
|
from ..utils import url_path_join
|
||||||
from .mocking import public_host
|
from .mocking import public_host
|
||||||
from .mocking import public_url
|
from .mocking import public_url
|
||||||
@@ -76,178 +76,6 @@ def test_expiring_dict():
|
|||||||
assert cache.get('key', 'default') == 'cached value'
|
assert cache.get('key', 'default') == 'cached value'
|
||||||
|
|
||||||
|
|
||||||
def test_hub_auth():
|
|
||||||
auth = HubAuth(cookie_name='foo')
|
|
||||||
mock_model = {'name': 'onyxia'}
|
|
||||||
url = url_path_join(auth.api_url, "authorizations/cookie/foo/bar")
|
|
||||||
with requests_mock.Mocker() as m:
|
|
||||||
m.get(url, text=json.dumps(mock_model))
|
|
||||||
user_model = auth.user_for_cookie('bar')
|
|
||||||
assert user_model == mock_model
|
|
||||||
# check cache
|
|
||||||
user_model = auth.user_for_cookie('bar')
|
|
||||||
assert user_model == mock_model
|
|
||||||
|
|
||||||
with requests_mock.Mocker() as m:
|
|
||||||
m.get(url, status_code=404)
|
|
||||||
user_model = auth.user_for_cookie('bar', use_cache=False)
|
|
||||||
assert user_model is None
|
|
||||||
|
|
||||||
# invalidate cache with timer
|
|
||||||
mock_model = {'name': 'willow'}
|
|
||||||
with monotonic_future, requests_mock.Mocker() as m:
|
|
||||||
m.get(url, text=json.dumps(mock_model))
|
|
||||||
user_model = auth.user_for_cookie('bar')
|
|
||||||
assert user_model == mock_model
|
|
||||||
|
|
||||||
with requests_mock.Mocker() as m:
|
|
||||||
m.get(url, status_code=500)
|
|
||||||
with raises(HTTPError) as exc_info:
|
|
||||||
user_model = auth.user_for_cookie('bar', use_cache=False)
|
|
||||||
assert exc_info.value.status_code == 502
|
|
||||||
|
|
||||||
with requests_mock.Mocker() as m:
|
|
||||||
m.get(url, status_code=400)
|
|
||||||
with raises(HTTPError) as exc_info:
|
|
||||||
user_model = auth.user_for_cookie('bar', use_cache=False)
|
|
||||||
assert exc_info.value.status_code == 500
|
|
||||||
|
|
||||||
|
|
||||||
def test_hub_authenticated(request):
|
|
||||||
auth = HubAuth(cookie_name='jubal')
|
|
||||||
mock_model = {'name': 'jubalearly', 'groups': ['lions']}
|
|
||||||
cookie_url = url_path_join(auth.api_url, "authorizations/cookie", auth.cookie_name)
|
|
||||||
good_url = url_path_join(cookie_url, "early")
|
|
||||||
bad_url = url_path_join(cookie_url, "late")
|
|
||||||
|
|
||||||
class TestHandler(HubAuthenticated, RequestHandler):
|
|
||||||
hub_auth = auth
|
|
||||||
|
|
||||||
@authenticated
|
|
||||||
def get(self):
|
|
||||||
self.finish(self.get_current_user())
|
|
||||||
|
|
||||||
# start hub-authenticated service in a thread:
|
|
||||||
port = 50505
|
|
||||||
q = Queue()
|
|
||||||
|
|
||||||
def run():
|
|
||||||
asyncio.set_event_loop(asyncio.new_event_loop())
|
|
||||||
app = Application([('/*', TestHandler)], login_url=auth.login_url)
|
|
||||||
|
|
||||||
http_server = HTTPServer(app)
|
|
||||||
http_server.listen(port)
|
|
||||||
loop = IOLoop.current()
|
|
||||||
loop.add_callback(lambda: q.put(loop))
|
|
||||||
loop.start()
|
|
||||||
|
|
||||||
t = Thread(target=run)
|
|
||||||
t.start()
|
|
||||||
|
|
||||||
def finish_thread():
|
|
||||||
loop.add_callback(loop.stop)
|
|
||||||
t.join(timeout=30)
|
|
||||||
assert not t.is_alive()
|
|
||||||
|
|
||||||
request.addfinalizer(finish_thread)
|
|
||||||
|
|
||||||
# wait for thread to start
|
|
||||||
loop = q.get(timeout=10)
|
|
||||||
|
|
||||||
with requests_mock.Mocker(real_http=True) as m:
|
|
||||||
# no cookie
|
|
||||||
r = requests.get('http://127.0.0.1:%i' % port, allow_redirects=False)
|
|
||||||
r.raise_for_status()
|
|
||||||
assert r.status_code == 302
|
|
||||||
assert auth.login_url in r.headers['Location']
|
|
||||||
|
|
||||||
# wrong cookie
|
|
||||||
m.get(bad_url, status_code=404)
|
|
||||||
r = requests.get(
|
|
||||||
'http://127.0.0.1:%i' % port,
|
|
||||||
cookies={'jubal': 'late'},
|
|
||||||
allow_redirects=False,
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
assert r.status_code == 302
|
|
||||||
assert auth.login_url in r.headers['Location']
|
|
||||||
|
|
||||||
# clear the cache because we are going to request
|
|
||||||
# the same url again with a different result
|
|
||||||
auth.cache.clear()
|
|
||||||
|
|
||||||
# upstream 403
|
|
||||||
m.get(bad_url, status_code=403)
|
|
||||||
r = requests.get(
|
|
||||||
'http://127.0.0.1:%i' % port,
|
|
||||||
cookies={'jubal': 'late'},
|
|
||||||
allow_redirects=False,
|
|
||||||
)
|
|
||||||
assert r.status_code == 500
|
|
||||||
|
|
||||||
m.get(good_url, text=json.dumps(mock_model))
|
|
||||||
|
|
||||||
# no specific allowed user
|
|
||||||
r = requests.get(
|
|
||||||
'http://127.0.0.1:%i' % port,
|
|
||||||
cookies={'jubal': 'early'},
|
|
||||||
allow_redirects=False,
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
assert r.status_code == 200
|
|
||||||
|
|
||||||
# pass allowed user
|
|
||||||
TestHandler.hub_users = {'jubalearly'}
|
|
||||||
r = requests.get(
|
|
||||||
'http://127.0.0.1:%i' % port,
|
|
||||||
cookies={'jubal': 'early'},
|
|
||||||
allow_redirects=False,
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
assert r.status_code == 200
|
|
||||||
|
|
||||||
# no pass allowed ser
|
|
||||||
TestHandler.hub_users = {'kaylee'}
|
|
||||||
r = requests.get(
|
|
||||||
'http://127.0.0.1:%i' % port,
|
|
||||||
cookies={'jubal': 'early'},
|
|
||||||
allow_redirects=False,
|
|
||||||
)
|
|
||||||
assert r.status_code == 403
|
|
||||||
|
|
||||||
# pass allowed group
|
|
||||||
TestHandler.hub_groups = {'lions'}
|
|
||||||
r = requests.get(
|
|
||||||
'http://127.0.0.1:%i' % port,
|
|
||||||
cookies={'jubal': 'early'},
|
|
||||||
allow_redirects=False,
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
assert r.status_code == 200
|
|
||||||
|
|
||||||
# no pass allowed group
|
|
||||||
TestHandler.hub_groups = {'tigers'}
|
|
||||||
r = requests.get(
|
|
||||||
'http://127.0.0.1:%i' % port,
|
|
||||||
cookies={'jubal': 'early'},
|
|
||||||
allow_redirects=False,
|
|
||||||
)
|
|
||||||
assert r.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
async def test_hubauth_cookie(app, mockservice_url):
|
|
||||||
"""Test HubAuthenticated service with user cookies"""
|
|
||||||
cookies = await app.login_user('badger')
|
|
||||||
r = await async_requests.get(
|
|
||||||
public_url(app, mockservice_url) + '/whoami/', cookies=cookies
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
print(r.text)
|
|
||||||
reply = r.json()
|
|
||||||
sub_reply = {key: reply.get(key, 'missing') for key in ['name', 'admin']}
|
|
||||||
assert sub_reply == {'name': 'badger', 'admin': False}
|
|
||||||
|
|
||||||
|
|
||||||
async def test_hubauth_token(app, mockservice_url):
|
async def test_hubauth_token(app, mockservice_url):
|
||||||
"""Test HubAuthenticated service with user API tokens"""
|
"""Test HubAuthenticated service with user API tokens"""
|
||||||
u = add_user(app.db, name='river')
|
u = add_user(app.db, name='river')
|
||||||
@@ -295,8 +123,10 @@ async def test_hubauth_service_token(app, mockservice_url):
|
|||||||
r = await async_requests.get(
|
r = await async_requests.get(
|
||||||
public_url(app, mockservice_url) + '/whoami/',
|
public_url(app, mockservice_url) + '/whoami/',
|
||||||
headers={'Authorization': 'token %s' % token},
|
headers={'Authorization': 'token %s' % token},
|
||||||
|
allow_redirects=False,
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
assert r.status_code == 200
|
||||||
reply = r.json()
|
reply = r.json()
|
||||||
assert reply == {'kind': 'service', 'name': name, 'admin': False, 'roles': []}
|
assert reply == {'kind': 'service', 'name': name, 'admin': False, 'roles': []}
|
||||||
assert not r.cookies
|
assert not r.cookies
|
||||||
|
Reference in New Issue
Block a user