mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-15 14:03:02 +00:00
Merge pull request #2127 from minrk/oauthlib
switch to oauthlib from python-oauth2
This commit is contained in:
2
.flake8
2
.flake8
@@ -10,7 +10,7 @@
|
||||
# E402: module level import not at top of file
|
||||
# I100: Import statements are in the wrong order
|
||||
# I101: Imported names are in the wrong order. Should be
|
||||
ignore = E, C, W, F401, F403, F811, F841, E402, I100, I101
|
||||
ignore = E, C, W, F401, F403, F811, F841, E402, I100, I101, D400
|
||||
|
||||
exclude =
|
||||
.cache,
|
||||
|
@@ -5,14 +5,20 @@
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import (
|
||||
parse_qsl,
|
||||
quote,
|
||||
urlencode,
|
||||
urlparse,
|
||||
urlunparse,
|
||||
)
|
||||
|
||||
from oauth2.web.tornado import OAuth2Handler
|
||||
from oauthlib import oauth2
|
||||
from tornado import web
|
||||
|
||||
from .. import orm
|
||||
from ..user import User
|
||||
from ..utils import token_authenticated
|
||||
from ..utils import token_authenticated, compare_token
|
||||
from .base import BaseHandler, APIHandler
|
||||
|
||||
|
||||
@@ -98,24 +104,190 @@ class CookieAPIHandler(APIHandler):
|
||||
self.write(json.dumps(self.user_model(user)))
|
||||
|
||||
|
||||
class OAuthHandler(BaseHandler, OAuth2Handler):
|
||||
"""Implement OAuth provider handlers
|
||||
class OAuthHandler:
|
||||
def extract_oauth_params(self):
|
||||
"""extract oauthlib params from a request
|
||||
|
||||
OAuth2Handler sets `self.provider` in initialize,
|
||||
but we are already passing the Provider object via settings.
|
||||
"""
|
||||
@property
|
||||
def provider(self):
|
||||
return self.settings['oauth_provider']
|
||||
Returns:
|
||||
|
||||
def initialize(self):
|
||||
pass
|
||||
(uri, http_method, body, headers)
|
||||
"""
|
||||
return (
|
||||
self.request.uri,
|
||||
self.request.method,
|
||||
self.request.body,
|
||||
self.request.headers,
|
||||
)
|
||||
|
||||
def make_absolute_redirect_uri(self, uri):
|
||||
"""Make absolute redirect URIs
|
||||
|
||||
internal redirect uris, e.g. `/user/foo/oauth_handler`
|
||||
are allowed in jupyterhub, but oauthlib prohibits them.
|
||||
Add `$HOST` header to redirect_uri to make them acceptable.
|
||||
|
||||
Currently unused in favor of monkeypatching
|
||||
oauthlib.is_absolute_uri to skip the check
|
||||
"""
|
||||
redirect_uri = self.get_argument('redirect_uri')
|
||||
if not redirect_uri or not redirect_uri.startswith('/'):
|
||||
return uri
|
||||
# make absolute local redirects full URLs
|
||||
# to satisfy oauthlib's absolute URI requirement
|
||||
redirect_uri = self.request.protocol + "://" + self.request.headers['Host'] + redirect_uri
|
||||
parsed_url = urlparse(uri)
|
||||
query_list = parse_qsl(parsed_url.query, keep_blank_values=True)
|
||||
for idx, item in enumerate(query_list):
|
||||
if item[0] == 'redirect_uri':
|
||||
query_list[idx] = ('redirect_uri', redirect_uri)
|
||||
break
|
||||
|
||||
return urlunparse(
|
||||
urlparse(uri)
|
||||
._replace(query=urlencode(query_list))
|
||||
)
|
||||
|
||||
def add_credentials(self, credentials=None):
|
||||
"""Add oauth credentials
|
||||
|
||||
Adds user, session_id, client to oauth credentials
|
||||
"""
|
||||
if credentials is None:
|
||||
credentials = {}
|
||||
else:
|
||||
credentials = credentials.copy()
|
||||
|
||||
session_id = self.get_session_cookie()
|
||||
if session_id is None:
|
||||
session_id = self.set_session_cookie()
|
||||
|
||||
user = self.get_current_user()
|
||||
|
||||
# Extra credentials we need in the validator
|
||||
credentials.update({
|
||||
'user': user,
|
||||
'handler': self,
|
||||
'session_id': session_id,
|
||||
})
|
||||
return credentials
|
||||
|
||||
def send_oauth_response(self, headers, body, status):
|
||||
"""Send oauth response from provider return values
|
||||
|
||||
Provider methods return headers, body, and status
|
||||
to be set on the response.
|
||||
|
||||
This method applies these values to the Handler
|
||||
and sends the response.
|
||||
"""
|
||||
self.set_status(status)
|
||||
for key, value in headers.items():
|
||||
self.set_header(key, value)
|
||||
if body:
|
||||
self.write(body)
|
||||
|
||||
|
||||
class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
|
||||
"""Implement OAuth authorization endpoint(s)"""
|
||||
|
||||
def _complete_login(self, uri, headers, scopes, credentials):
|
||||
try:
|
||||
headers, body, status = self.oauth_provider.create_authorization_response(
|
||||
uri, 'POST', '', headers, scopes, credentials)
|
||||
|
||||
except oauth2.FatalClientError as e:
|
||||
# TODO: human error page
|
||||
raise
|
||||
self.send_oauth_response(headers, body, status)
|
||||
|
||||
@web.authenticated
|
||||
def get(self):
|
||||
"""GET /oauth/authorization
|
||||
|
||||
Render oauth confirmation page:
|
||||
"Server at ... would like permission to ...".
|
||||
|
||||
Users accessing their own server will skip confirmation.
|
||||
"""
|
||||
|
||||
uri, http_method, body, headers = self.extract_oauth_params()
|
||||
try:
|
||||
scopes, credentials = self.oauth_provider.validate_authorization_request(
|
||||
uri, http_method, body, headers)
|
||||
credentials = self.add_credentials(credentials)
|
||||
client = self.oauth_provider.fetch_by_client_id(credentials['client_id'])
|
||||
if client.redirect_uri.startswith(self.get_current_user().url):
|
||||
self.log.debug(
|
||||
"Skipping oauth confirmation for %s accessing %s",
|
||||
self.get_current_user(), client.description,
|
||||
)
|
||||
# access to my own server doesn't require oauth confirmation
|
||||
# this is the pre-1.0 behavior for all oauth
|
||||
self._complete_login(uri, headers, scopes, credentials)
|
||||
return
|
||||
|
||||
# Render oauth 'Authorize application...' page
|
||||
self.write(
|
||||
self.render_template(
|
||||
"oauth.html",
|
||||
scopes=scopes,
|
||||
oauth_client=client,
|
||||
)
|
||||
)
|
||||
|
||||
# Errors that should be shown to the user on the provider website
|
||||
except oauth2.FatalClientError as e:
|
||||
raise web.HTTPError(e.status_code, e.description)
|
||||
|
||||
# Errors embedded in the redirect URI back to the client
|
||||
except oauth2.OAuth2Error as e:
|
||||
self.log.error("OAuth error: %s", e.description)
|
||||
self.redirect(e.in_uri(e.redirect_uri))
|
||||
|
||||
@web.authenticated
|
||||
def post(self):
|
||||
uri, http_method, body, headers = self.extract_oauth_params()
|
||||
referer = self.request.headers.get('Referer', 'no referer')
|
||||
full_url = self.request.full_url()
|
||||
if referer != full_url:
|
||||
# OAuth post must be made to the URL it came from
|
||||
self.log.error("OAuth POST from %s != %s", referer, full_url)
|
||||
raise web.HTTPError(403, "Authorization form must be sent from authorization page")
|
||||
|
||||
# The scopes the user actually authorized, i.e. checkboxes
|
||||
# that were selected.
|
||||
scopes = self.get_arguments('scopes')
|
||||
# credentials we need in the validator
|
||||
credentials = self.add_credentials()
|
||||
|
||||
try:
|
||||
headers, body, status = self.oauth_provider.create_authorization_response(
|
||||
uri, http_method, body, headers, scopes, credentials,
|
||||
)
|
||||
except oauth2.FatalClientError as e:
|
||||
raise web.HTTPError(e.status_code, e.description)
|
||||
else:
|
||||
self.send_oauth_response(headers, body, status)
|
||||
|
||||
|
||||
class OAuthTokenHandler(OAuthHandler, APIHandler):
|
||||
def post(self):
|
||||
uri, http_method, body, headers = self.extract_oauth_params()
|
||||
credentials = {}
|
||||
|
||||
try:
|
||||
headers, body, status = self.oauth_provider.create_token_response(
|
||||
uri, http_method, body, headers, credentials)
|
||||
except oauth2.FatalClientError as e:
|
||||
raise web.HTTPError(e.status_code, e.description)
|
||||
else:
|
||||
self.send_oauth_response(headers, body, status)
|
||||
|
||||
|
||||
default_handlers = [
|
||||
(r"/api/authorizations/cookie/([^/]+)(?:/([^/]+))?", CookieAPIHandler),
|
||||
(r"/api/authorizations/token/([^/]+)", TokenAPIHandler),
|
||||
(r"/api/authorizations/token", TokenAPIHandler),
|
||||
(r"/api/oauth2/authorize", OAuthHandler),
|
||||
(r"/api/oauth2/token", OAuthHandler),
|
||||
(r"/api/oauth2/authorize", OAuthAuthorizeHandler),
|
||||
(r"/api/oauth2/token", OAuthTokenHandler),
|
||||
]
|
||||
|
@@ -54,7 +54,7 @@ from .services.service import Service
|
||||
from . import crypto
|
||||
from . import dbutil, orm
|
||||
from .user import UserDict
|
||||
from .oauth.store import make_provider
|
||||
from .oauth.provider import make_provider
|
||||
from ._data import DATA_FILES_PATH
|
||||
from .log import CoroutineLogFormatter, log_request
|
||||
from .proxy import Proxy, ConfigurableHTTPProxy
|
||||
@@ -1331,7 +1331,7 @@ class JupyterHub(Application):
|
||||
host = '%s://services.%s' % (parsed.scheme, parsed.netloc)
|
||||
else:
|
||||
domain = host = ''
|
||||
client_store = self.oauth_provider.client_authenticator.client_store
|
||||
|
||||
for spec in self.services:
|
||||
if 'name' not in spec:
|
||||
raise ValueError('service spec must have a name: %r' % spec)
|
||||
@@ -1389,7 +1389,7 @@ class JupyterHub(Application):
|
||||
service.orm.server = None
|
||||
|
||||
if service.oauth_available:
|
||||
client_store.add_client(
|
||||
self.oauth_provider.add_client(
|
||||
client_id=service.oauth_client_id,
|
||||
client_secret=service.api_token,
|
||||
redirect_uri=service.oauth_redirect_uri,
|
||||
@@ -1489,9 +1489,9 @@ class JupyterHub(Application):
|
||||
def init_oauth(self):
|
||||
base_url = self.hub.base_url
|
||||
self.oauth_provider = make_provider(
|
||||
lambda : self.db,
|
||||
lambda: self.db,
|
||||
url_prefix=url_path_join(base_url, 'api/oauth2'),
|
||||
login_url=url_path_join(base_url, 'login')
|
||||
login_url=url_path_join(base_url, 'login'),
|
||||
)
|
||||
|
||||
def cleanup_oauth_clients(self):
|
||||
@@ -1507,7 +1507,6 @@ class JupyterHub(Application):
|
||||
for spawner in user.spawners.values():
|
||||
oauth_client_ids.add(spawner.oauth_client_id)
|
||||
|
||||
client_store = self.oauth_provider.client_authenticator.client_store
|
||||
for i, oauth_client in enumerate(self.db.query(orm.OAuthClient)):
|
||||
if oauth_client.identifier not in oauth_client_ids:
|
||||
self.log.warning("Deleting OAuth client %s", oauth_client.identifier)
|
||||
|
591
jupyterhub/oauth/provider.py
Normal file
591
jupyterhub/oauth/provider.py
Normal file
@@ -0,0 +1,591 @@
|
||||
"""Utilities for hooking up oauth2 to JupyterHub's database
|
||||
|
||||
implements https://oauthlib.readthedocs.io/en/latest/oauth2/server.html
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from oauthlib.oauth2 import RequestValidator, WebApplicationServer
|
||||
|
||||
from sqlalchemy.orm import scoped_session
|
||||
from tornado.escape import url_escape
|
||||
from tornado.log import app_log
|
||||
from tornado import web
|
||||
|
||||
from .. import orm
|
||||
from ..utils import url_path_join, hash_token, compare_token
|
||||
|
||||
|
||||
# patch absolute-uri check
|
||||
# because we want to allow relative uri oauth
|
||||
# for internal services
|
||||
from oauthlib.oauth2.rfc6749.grant_types import authorization_code
|
||||
authorization_code.is_absolute_uri = lambda uri: True
|
||||
|
||||
|
||||
class JupyterHubRequestValidator(RequestValidator):
|
||||
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
super().__init__()
|
||||
|
||||
def authenticate_client(self, request, *args, **kwargs):
|
||||
"""Authenticate client through means outside the OAuth 2 spec.
|
||||
Means of authentication is negotiated beforehand and may for example
|
||||
be `HTTP Basic Authentication Scheme`_ which utilizes the Authorization
|
||||
header.
|
||||
Headers may be accesses through request.headers and parameters found in
|
||||
both body and query can be obtained by direct attribute access, i.e.
|
||||
request.client_id for client_id in the URL query.
|
||||
:param request: oauthlib.common.Request
|
||||
:rtype: True or False
|
||||
Method is used by:
|
||||
- Authorization Code Grant
|
||||
- Resource Owner Password Credentials Grant (may be disabled)
|
||||
- Client Credentials Grant
|
||||
- Refresh Token Grant
|
||||
.. _`HTTP Basic Authentication Scheme`: https://tools.ietf.org/html/rfc1945#section-11.1
|
||||
"""
|
||||
app_log.debug("authenticate_client %s", request)
|
||||
client_id = request.client_id
|
||||
client_secret = request.client_secret
|
||||
oauth_client = (
|
||||
self.db
|
||||
.query(orm.OAuthClient)
|
||||
.filter_by(identifier=client_id)
|
||||
.first()
|
||||
)
|
||||
if oauth_client is None:
|
||||
return False
|
||||
if not compare_token(oauth_client.secret, client_secret):
|
||||
app_log.warning("Client secret mismatch for %s", client_id)
|
||||
return False
|
||||
|
||||
request.client = oauth_client
|
||||
return True
|
||||
|
||||
def authenticate_client_id(self, client_id, request, *args, **kwargs):
|
||||
"""Ensure client_id belong to a non-confidential client.
|
||||
A non-confidential client is one that is not required to authenticate
|
||||
through other means, such as using HTTP Basic.
|
||||
Note, while not strictly necessary it can often be very convenient
|
||||
to set request.client to the client object associated with the
|
||||
given client_id.
|
||||
:param request: oauthlib.common.Request
|
||||
:rtype: True or False
|
||||
Method is used by:
|
||||
- Authorization Code Grant
|
||||
"""
|
||||
orm_client = (
|
||||
self.db
|
||||
.query(orm.OAuthClient)
|
||||
.filter_by(identifier=client_id)
|
||||
.first()
|
||||
)
|
||||
if orm_client is None:
|
||||
app_log.warning("No such oauth client %s", client_id)
|
||||
return False
|
||||
request.client = orm_client
|
||||
return True
|
||||
|
||||
def confirm_redirect_uri(self, client_id, code, redirect_uri, client,
|
||||
*args, **kwargs):
|
||||
"""Ensure that the authorization process represented by this authorization
|
||||
code began with this 'redirect_uri'.
|
||||
If the client specifies a redirect_uri when obtaining code then that
|
||||
redirect URI must be bound to the code and verified equal in this
|
||||
method, according to RFC 6749 section 4.1.3. Do not compare against
|
||||
the client's allowed redirect URIs, but against the URI used when the
|
||||
code was saved.
|
||||
:param client_id: Unicode client identifier
|
||||
:param code: Unicode authorization_code.
|
||||
:param redirect_uri: Unicode absolute URI
|
||||
:param client: Client object set by you, see authenticate_client.
|
||||
:rtype: True or False
|
||||
Method is used by:
|
||||
- Authorization Code Grant (during token request)
|
||||
"""
|
||||
# TODO: record redirect_uri used during oauth
|
||||
# if we ever support multiple destinations
|
||||
app_log.debug("confirm_redirect_uri: client_id=%s, redirect_uri=%s",
|
||||
client_id, redirect_uri,
|
||||
)
|
||||
if redirect_uri == client.redirect_uri:
|
||||
return True
|
||||
else:
|
||||
app_log.warning("Redirect uri %s != %s", redirect_uri, client.redirect_uri)
|
||||
return False
|
||||
|
||||
def get_default_redirect_uri(self, client_id, request, *args, **kwargs):
|
||||
"""Get the default redirect URI for the client.
|
||||
:param client_id: Unicode client identifier
|
||||
:param request: The HTTP Request (oauthlib.common.Request)
|
||||
:rtype: The default redirect URI for the client
|
||||
Method is used by:
|
||||
- Authorization Code Grant
|
||||
- Implicit Grant
|
||||
"""
|
||||
orm_client = (
|
||||
self.db
|
||||
.query(orm.OAuthClient)
|
||||
.filter_by(identifier=client_id)
|
||||
.first()
|
||||
)
|
||||
if orm_client is None:
|
||||
raise KeyError(client_id)
|
||||
return orm_client.redirect_uri
|
||||
|
||||
def get_default_scopes(self, client_id, request, *args, **kwargs):
|
||||
"""Get the default scopes for the client.
|
||||
:param client_id: Unicode client identifier
|
||||
:param request: The HTTP Request (oauthlib.common.Request)
|
||||
:rtype: List of default scopes
|
||||
Method is used by all core grant types:
|
||||
- Authorization Code Grant
|
||||
- Implicit Grant
|
||||
- Resource Owner Password Credentials Grant
|
||||
- Client Credentials grant
|
||||
"""
|
||||
return ['identify']
|
||||
|
||||
def get_original_scopes(self, refresh_token, request, *args, **kwargs):
|
||||
"""Get the list of scopes associated with the refresh token.
|
||||
:param refresh_token: Unicode refresh token
|
||||
:param request: The HTTP Request (oauthlib.common.Request)
|
||||
:rtype: List of scopes.
|
||||
Method is used by:
|
||||
- Refresh token grant
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def is_within_original_scope(self, request_scopes, refresh_token, request, *args, **kwargs):
|
||||
"""Check if requested scopes are within a scope of the refresh token.
|
||||
When access tokens are refreshed the scope of the new token
|
||||
needs to be within the scope of the original token. This is
|
||||
ensured by checking that all requested scopes strings are on
|
||||
the list returned by the get_original_scopes. If this check
|
||||
fails, is_within_original_scope is called. The method can be
|
||||
used in situations where returning all valid scopes from the
|
||||
get_original_scopes is not practical.
|
||||
:param request_scopes: A list of scopes that were requested by client
|
||||
:param refresh_token: Unicode refresh_token
|
||||
:param request: The HTTP Request (oauthlib.common.Request)
|
||||
:rtype: True or False
|
||||
Method is used by:
|
||||
- Refresh token grant
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def invalidate_authorization_code(self, client_id, code, request, *args, **kwargs):
|
||||
"""Invalidate an authorization code after use.
|
||||
:param client_id: Unicode client identifier
|
||||
:param code: The authorization code grant (request.code).
|
||||
:param request: The HTTP Request (oauthlib.common.Request)
|
||||
Method is used by:
|
||||
- Authorization Code Grant
|
||||
"""
|
||||
app_log.debug("Deleting oauth code %s... for %s", code[:3], client_id)
|
||||
orm_code = self.db.query(orm.OAuthCode).filter_by(code=code).first()
|
||||
if orm_code is not None:
|
||||
self.db.delete(orm_code)
|
||||
self.db.commit()
|
||||
|
||||
def revoke_token(self, token, token_type_hint, request, *args, **kwargs):
|
||||
"""Revoke an access or refresh token.
|
||||
:param token: The token string.
|
||||
:param token_type_hint: access_token or refresh_token.
|
||||
:param request: The HTTP Request (oauthlib.common.Request)
|
||||
Method is used by:
|
||||
- Revocation Endpoint
|
||||
"""
|
||||
app_log.debug("Revoking %s %s", token_type_hint, token[:3] + '...')
|
||||
raise NotImplementedError('Subclasses must implement this method.')
|
||||
|
||||
def save_authorization_code(self, client_id, code, request, *args, **kwargs):
|
||||
"""Persist the authorization_code.
|
||||
The code should at minimum be stored with:
|
||||
- the client_id (client_id)
|
||||
- the redirect URI used (request.redirect_uri)
|
||||
- a resource owner / user (request.user)
|
||||
- the authorized scopes (request.scopes)
|
||||
- the client state, if given (code.get('state'))
|
||||
The 'code' argument is actually a dictionary, containing at least a
|
||||
'code' key with the actual authorization code:
|
||||
{'code': 'sdf345jsdf0934f'}
|
||||
It may also have a 'state' key containing a nonce for the client, if it
|
||||
chose to send one. That value should be saved and used in
|
||||
'validate_code'.
|
||||
It may also have a 'claims' parameter which, when present, will be a dict
|
||||
deserialized from JSON as described at
|
||||
http://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter
|
||||
This value should be saved in this method and used again in 'validate_code'.
|
||||
:param client_id: Unicode client identifier
|
||||
:param code: A dict of the authorization code grant and, optionally, state.
|
||||
:param request: The HTTP Request (oauthlib.common.Request)
|
||||
Method is used by:
|
||||
- Authorization Code Grant
|
||||
"""
|
||||
log_code = code.get('code', 'undefined')[:3] + '...'
|
||||
app_log.debug("Saving authorization code %s, %s, %s, %s", client_id, log_code, args, kwargs)
|
||||
orm_client = (
|
||||
self.db
|
||||
.query(orm.OAuthClient)
|
||||
.filter_by(identifier=client_id)
|
||||
.first()
|
||||
)
|
||||
if orm_client is None:
|
||||
raise ValueError("No such client: %s" % client_id)
|
||||
|
||||
orm_code = orm.OAuthCode(
|
||||
client=orm_client,
|
||||
code=code['code'],
|
||||
# oauth has 5 minutes to complete
|
||||
expires_at=int(datetime.utcnow().timestamp() + 300),
|
||||
# TODO: persist oauth scopes
|
||||
# scopes=request.scopes,
|
||||
user=request.user.orm_user,
|
||||
redirect_uri=orm_client.redirect_uri,
|
||||
session_id=request.session_id,
|
||||
)
|
||||
self.db.add(orm_code)
|
||||
self.db.commit()
|
||||
|
||||
def get_authorization_code_scopes(self, client_id, code, redirect_uri, request):
|
||||
""" Extracts scopes from saved authorization code.
|
||||
The scopes returned by this method is used to route token requests
|
||||
based on scopes passed to Authorization Code requests.
|
||||
With that the token endpoint knows when to include OpenIDConnect
|
||||
id_token in token response only based on authorization code scopes.
|
||||
Only code param should be sufficient to retrieve grant code from
|
||||
any storage you are using, `client_id` and `redirect_uri` can gave a
|
||||
blank value `""` don't forget to check it before using those values
|
||||
in a select query if a database is used.
|
||||
:param client_id: Unicode client identifier
|
||||
:param code: Unicode authorization code grant
|
||||
:param redirect_uri: Unicode absolute URI
|
||||
:return: A list of scope
|
||||
Method is used by:
|
||||
- Authorization Token Grant Dispatcher
|
||||
"""
|
||||
raise NotImplementedError("TODO")
|
||||
|
||||
def save_token(self, token, request, *args, **kwargs):
|
||||
"""Persist the token with a token type specific method.
|
||||
Currently, only save_bearer_token is supported.
|
||||
"""
|
||||
return self.save_bearer_token(token, request, *args, **kwargs)
|
||||
|
||||
def save_bearer_token(self, token, request, *args, **kwargs):
|
||||
"""Persist the Bearer token.
|
||||
The Bearer token should at minimum be associated with:
|
||||
- a client and it's client_id, if available
|
||||
- a resource owner / user (request.user)
|
||||
- authorized scopes (request.scopes)
|
||||
- an expiration time
|
||||
- a refresh token, if issued
|
||||
- a claims document, if present in request.claims
|
||||
The Bearer token dict may hold a number of items::
|
||||
{
|
||||
'token_type': 'Bearer',
|
||||
'access_token': 'askfjh234as9sd8',
|
||||
'expires_in': 3600,
|
||||
'scope': 'string of space separated authorized scopes',
|
||||
'refresh_token': '23sdf876234', # if issued
|
||||
'state': 'given_by_client', # if supplied by client
|
||||
}
|
||||
Note that while "scope" is a string-separated list of authorized scopes,
|
||||
the original list is still available in request.scopes.
|
||||
The token dict is passed as a reference so any changes made to the dictionary
|
||||
will go back to the user. If additional information must return to the client
|
||||
user, and it is only possible to get this information after writing the token
|
||||
to storage, it should be added to the token dictionary. If the token
|
||||
dictionary must be modified but the changes should not go back to the user,
|
||||
a copy of the dictionary must be made before making the changes.
|
||||
Also note that if an Authorization Code grant request included a valid claims
|
||||
parameter (for OpenID Connect) then the request.claims property will contain
|
||||
the claims dict, which should be saved for later use when generating the
|
||||
id_token and/or UserInfo response content.
|
||||
:param token: A Bearer token dict
|
||||
:param request: The HTTP Request (oauthlib.common.Request)
|
||||
:rtype: The default redirect URI for the client
|
||||
Method is used by all core grant types issuing Bearer tokens:
|
||||
- Authorization Code Grant
|
||||
- Implicit Grant
|
||||
- Resource Owner Password Credentials Grant (might not associate a client)
|
||||
- Client Credentials grant
|
||||
"""
|
||||
log_token = {}
|
||||
log_token.update(token)
|
||||
scopes = token['scope'].split(' ')
|
||||
# TODO:
|
||||
if scopes != ['identify']:
|
||||
raise ValueError("Only 'identify' scope is supported")
|
||||
# redact sensitive keys in log
|
||||
for key in ('access_token', 'refresh_token', 'state'):
|
||||
if key in token:
|
||||
value = token[key]
|
||||
if isinstance(value, str):
|
||||
log_token[key] = 'REDACTED'
|
||||
app_log.debug("Saving bearer token %s", log_token)
|
||||
if request.user is None:
|
||||
raise ValueError("No user for access token: %s" % request.user)
|
||||
client = self.db.query(orm.OAuthClient).filter_by(identifier=request.client.client_id).first()
|
||||
orm_access_token = orm.OAuthAccessToken(
|
||||
client=client,
|
||||
grant_type=orm.GrantType.authorization_code,
|
||||
expires_at=datetime.utcnow().timestamp() + token['expires_in'],
|
||||
refresh_token=token['refresh_token'],
|
||||
# TODO: save scopes,
|
||||
# scopes=scopes,
|
||||
token=token['access_token'],
|
||||
session_id=request.session_id,
|
||||
user=request.user,
|
||||
)
|
||||
self.db.add(orm_access_token)
|
||||
self.db.commit()
|
||||
return client.redirect_uri
|
||||
|
||||
def validate_bearer_token(self, token, scopes, request):
|
||||
"""Ensure the Bearer token is valid and authorized access to scopes.
|
||||
:param token: A string of random characters.
|
||||
:param scopes: A list of scopes associated with the protected resource.
|
||||
:param request: The HTTP Request (oauthlib.common.Request)
|
||||
A key to OAuth 2 security and restricting impact of leaked tokens is
|
||||
the short expiration time of tokens, *always ensure the token has not
|
||||
expired!*.
|
||||
Two different approaches to scope validation:
|
||||
1) all(scopes). The token must be authorized access to all scopes
|
||||
associated with the resource. For example, the
|
||||
token has access to ``read-only`` and ``images``,
|
||||
thus the client can view images but not upload new.
|
||||
Allows for fine grained access control through
|
||||
combining various scopes.
|
||||
2) any(scopes). The token must be authorized access to one of the
|
||||
scopes associated with the resource. For example,
|
||||
token has access to ``read-only-images``.
|
||||
Allows for fine grained, although arguably less
|
||||
convenient, access control.
|
||||
A powerful way to use scopes would mimic UNIX ACLs and see a scope
|
||||
as a group with certain privileges. For a restful API these might
|
||||
map to HTTP verbs instead of read, write and execute.
|
||||
Note, the request.user attribute can be set to the resource owner
|
||||
associated with this token. Similarly the request.client and
|
||||
request.scopes attribute can be set to associated client object
|
||||
and authorized scopes. If you then use a decorator such as the
|
||||
one provided for django these attributes will be made available
|
||||
in all protected views as keyword arguments.
|
||||
:param token: Unicode Bearer token
|
||||
:param scopes: List of scopes (defined by you)
|
||||
:param request: The HTTP Request (oauthlib.common.Request)
|
||||
:rtype: True or False
|
||||
Method is indirectly used by all core Bearer token issuing grant types:
|
||||
- Authorization Code Grant
|
||||
- Implicit Grant
|
||||
- Resource Owner Password Credentials Grant
|
||||
- Client Credentials Grant
|
||||
"""
|
||||
raise NotImplementedError('Subclasses must implement this method.')
|
||||
|
||||
def validate_client_id(self, client_id, request, *args, **kwargs):
|
||||
"""Ensure client_id belong to a valid and active client.
|
||||
Note, while not strictly necessary it can often be very convenient
|
||||
to set request.client to the client object associated with the
|
||||
given client_id.
|
||||
:param request: oauthlib.common.Request
|
||||
:rtype: True or False
|
||||
Method is used by:
|
||||
- Authorization Code Grant
|
||||
- Implicit Grant
|
||||
"""
|
||||
app_log.debug("Validating client id %s", client_id)
|
||||
orm_client = (
|
||||
self.db
|
||||
.query(orm.OAuthClient)
|
||||
.filter_by(identifier=client_id)
|
||||
.first()
|
||||
)
|
||||
if orm_client is None:
|
||||
return False
|
||||
request.client = orm_client
|
||||
return True
|
||||
|
||||
def validate_code(self, client_id, code, client, request, *args, **kwargs):
|
||||
"""Verify that the authorization_code is valid and assigned to the given
|
||||
client.
|
||||
Before returning true, set the following based on the information stored
|
||||
with the code in 'save_authorization_code':
|
||||
- request.user
|
||||
- request.state (if given)
|
||||
- request.scopes
|
||||
- request.claims (if given)
|
||||
OBS! The request.user attribute should be set to the resource owner
|
||||
associated with this authorization code. Similarly request.scopes
|
||||
must also be set.
|
||||
The request.claims property, if it was given, should assigned a dict.
|
||||
:param client_id: Unicode client identifier
|
||||
:param code: Unicode authorization code
|
||||
:param client: Client object set by you, see authenticate_client.
|
||||
:param request: The HTTP Request (oauthlib.common.Request)
|
||||
:rtype: True or False
|
||||
Method is used by:
|
||||
- Authorization Code Grant
|
||||
"""
|
||||
orm_code = (
|
||||
self.db
|
||||
.query(orm.OAuthCode)
|
||||
.filter_by(code=code)
|
||||
.first()
|
||||
)
|
||||
if orm_code is None:
|
||||
app_log.debug("No such code: %s", code)
|
||||
return False
|
||||
if orm_code.client_id != client_id:
|
||||
app_log.debug(
|
||||
"OAuth code client id mismatch: %s != %s",
|
||||
client_id, orm_code.client_id,
|
||||
)
|
||||
return False
|
||||
request.user = orm_code.user
|
||||
request.session_id = orm_code.session_id
|
||||
# TODO: record state on oauth codes
|
||||
# TODO: specify scopes
|
||||
request.scopes = ['identify']
|
||||
return True
|
||||
|
||||
def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs):
|
||||
"""Ensure client is authorized to use the grant_type requested.
|
||||
:param client_id: Unicode client identifier
|
||||
:param grant_type: Unicode grant type, i.e. authorization_code, password.
|
||||
:param client: Client object set by you, see authenticate_client.
|
||||
:param request: The HTTP Request (oauthlib.common.Request)
|
||||
:rtype: True or False
|
||||
Method is used by:
|
||||
- Authorization Code Grant
|
||||
- Resource Owner Password Credentials Grant
|
||||
- Client Credentials Grant
|
||||
- Refresh Token Grant
|
||||
"""
|
||||
return grant_type == 'authorization_code'
|
||||
|
||||
def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs):
|
||||
"""Ensure client is authorized to redirect to the redirect_uri requested.
|
||||
All clients should register the absolute URIs of all URIs they intend
|
||||
to redirect to. The registration is outside of the scope of oauthlib.
|
||||
:param client_id: Unicode client identifier
|
||||
:param redirect_uri: Unicode absolute URI
|
||||
:param request: The HTTP Request (oauthlib.common.Request)
|
||||
:rtype: True or False
|
||||
Method is used by:
|
||||
- Authorization Code Grant
|
||||
- Implicit Grant
|
||||
"""
|
||||
app_log.debug("validate_redirect_uri: client_id=%s, redirect_uri=%s",
|
||||
client_id, redirect_uri,
|
||||
)
|
||||
orm_client = (
|
||||
self.db
|
||||
.query(orm.OAuthClient)
|
||||
.filter_by(identifier=client_id)
|
||||
.first()
|
||||
)
|
||||
if orm_client is None:
|
||||
app_log.warning("No such oauth client %s", client_id)
|
||||
return False
|
||||
if redirect_uri == orm_client.redirect_uri:
|
||||
return True
|
||||
else:
|
||||
app_log.warning("Redirect uri %s != %s", redirect_uri, orm_client.redirect_uri)
|
||||
return False
|
||||
|
||||
def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs):
|
||||
"""Ensure the Bearer token is valid and authorized access to scopes.
|
||||
OBS! The request.user attribute should be set to the resource owner
|
||||
associated with this refresh token.
|
||||
:param refresh_token: Unicode refresh token
|
||||
:param client: Client object set by you, see authenticate_client.
|
||||
:param request: The HTTP Request (oauthlib.common.Request)
|
||||
:rtype: True or False
|
||||
Method is used by:
|
||||
- Authorization Code Grant (indirectly by issuing refresh tokens)
|
||||
- Resource Owner Password Credentials Grant (also indirectly)
|
||||
- Refresh Token Grant
|
||||
"""
|
||||
return False
|
||||
raise NotImplementedError('Subclasses must implement this method.')
|
||||
|
||||
def validate_response_type(self, client_id, response_type, client, request, *args, **kwargs):
|
||||
"""Ensure client is authorized to use the response_type requested.
|
||||
:param client_id: Unicode client identifier
|
||||
:param response_type: Unicode response type, i.e. code, token.
|
||||
:param client: Client object set by you, see authenticate_client.
|
||||
:param request: The HTTP Request (oauthlib.common.Request)
|
||||
:rtype: True or False
|
||||
Method is used by:
|
||||
- Authorization Code Grant
|
||||
- Implicit Grant
|
||||
"""
|
||||
# TODO
|
||||
return True
|
||||
|
||||
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
|
||||
"""Ensure the client is authorized access to requested scopes.
|
||||
:param client_id: Unicode client identifier
|
||||
:param scopes: List of scopes (defined by you)
|
||||
:param client: Client object set by you, see authenticate_client.
|
||||
:param request: The HTTP Request (oauthlib.common.Request)
|
||||
:rtype: True or False
|
||||
Method is used by all core grant types:
|
||||
- Authorization Code Grant
|
||||
- Implicit Grant
|
||||
- Resource Owner Password Credentials Grant
|
||||
- Client Credentials Grant
|
||||
"""
|
||||
return True
|
||||
|
||||
|
||||
class JupyterHubOAuthServer(WebApplicationServer):
|
||||
def __init__(self, db, validator, *args, **kwargs):
|
||||
self.db = db
|
||||
super().__init__(validator, *args, **kwargs)
|
||||
|
||||
def add_client(self, client_id, client_secret, redirect_uri, description=''):
|
||||
"""Add a client
|
||||
|
||||
hash its client_secret before putting it in the database.
|
||||
"""
|
||||
# clear existing clients with same ID
|
||||
for orm_client in (
|
||||
self.db
|
||||
.query(orm.OAuthClient)\
|
||||
.filter_by(identifier=client_id)
|
||||
):
|
||||
self.db.delete(orm_client)
|
||||
self.db.commit()
|
||||
|
||||
orm_client = orm.OAuthClient(
|
||||
identifier=client_id,
|
||||
secret=hash_token(client_secret),
|
||||
redirect_uri=redirect_uri,
|
||||
description=description,
|
||||
)
|
||||
self.db.add(orm_client)
|
||||
self.db.commit()
|
||||
|
||||
def fetch_by_client_id(self, client_id):
|
||||
"""Find a client by its id"""
|
||||
return (
|
||||
self.db
|
||||
.query(orm.OAuthClient)
|
||||
.filter_by(identifier=client_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def make_provider(session_factory, url_prefix, login_url):
|
||||
"""Make an OAuth provider"""
|
||||
db = session_factory()
|
||||
validator = JupyterHubRequestValidator(db)
|
||||
server = JupyterHubOAuthServer(db, validator)
|
||||
return server
|
||||
|
@@ -1,262 +0,0 @@
|
||||
"""Utilities for hooking up oauth2 to JupyterHub's database
|
||||
|
||||
implements https://python-oauth2.readthedocs.io/en/latest/store.html
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
from oauth2.datatype import Client, AuthorizationCode
|
||||
from oauth2.error import AuthCodeNotFound, ClientNotFoundError, UserNotAuthenticated
|
||||
from oauth2.grant import AuthorizationCodeGrant
|
||||
from oauth2.web import AuthorizationCodeGrantSiteAdapter
|
||||
import oauth2.store
|
||||
from oauth2 import Provider
|
||||
from oauth2.tokengenerator import Uuid4 as UUID4
|
||||
|
||||
from sqlalchemy.orm import scoped_session
|
||||
from tornado.escape import url_escape
|
||||
|
||||
from .. import orm
|
||||
from ..utils import url_path_join, hash_token, compare_token
|
||||
|
||||
|
||||
class JupyterHubSiteAdapter(AuthorizationCodeGrantSiteAdapter):
|
||||
"""
|
||||
This adapter renders a confirmation page so the user can confirm the auth
|
||||
request.
|
||||
"""
|
||||
def __init__(self, login_url):
|
||||
self.login_url = login_url
|
||||
|
||||
def render_auth_page(self, request, response, environ, scopes, client):
|
||||
"""Auth page is a redirect to login page"""
|
||||
response.status_code = 302
|
||||
response.headers['Location'] = self.login_url + '?next={}'.format(
|
||||
url_escape(request.handler.request.path + '?' + request.handler.request.query)
|
||||
)
|
||||
return response
|
||||
|
||||
def authenticate(self, request, environ, scopes, client):
|
||||
handler = request.handler
|
||||
user = handler.get_current_user()
|
||||
# ensure session_id is set
|
||||
session_id = handler.get_session_cookie()
|
||||
if session_id is None:
|
||||
session_id = handler.set_session_cookie()
|
||||
if user:
|
||||
return {'session_id': session_id}, user.id
|
||||
else:
|
||||
raise UserNotAuthenticated()
|
||||
|
||||
def user_has_denied_access(self, request):
|
||||
# user can't deny access
|
||||
return False
|
||||
|
||||
|
||||
class HubDBMixin(object):
|
||||
"""Mixin for connecting to the hub database"""
|
||||
def __init__(self, session_factory):
|
||||
self.db = session_factory()
|
||||
|
||||
|
||||
class AccessTokenStore(HubDBMixin, oauth2.store.AccessTokenStore):
|
||||
"""OAuth2 AccessTokenStore, storing data in the Hub database"""
|
||||
|
||||
def save_token(self, access_token):
|
||||
"""
|
||||
Stores an access token in the database.
|
||||
|
||||
:param access_token: An instance of :class:`oauth2.datatype.AccessToken`.
|
||||
|
||||
"""
|
||||
|
||||
user = self.db.query(orm.User).filter_by(id=access_token.user_id).first()
|
||||
if user is None:
|
||||
raise ValueError("No user for access token: %s" % access_token.user_id)
|
||||
client = self.db.query(orm.OAuthClient).filter_by(identifier=access_token.client_id).first()
|
||||
orm_access_token = orm.OAuthAccessToken(
|
||||
client=client,
|
||||
grant_type=access_token.grant_type,
|
||||
expires_at=access_token.expires_at,
|
||||
refresh_token=access_token.refresh_token,
|
||||
refresh_expires_at=access_token.refresh_expires_at,
|
||||
token=access_token.token,
|
||||
session_id=access_token.data['session_id'],
|
||||
user=user,
|
||||
)
|
||||
self.db.add(orm_access_token)
|
||||
self.db.commit()
|
||||
|
||||
|
||||
class AuthCodeStore(HubDBMixin, oauth2.store.AuthCodeStore):
|
||||
"""
|
||||
OAuth2 AuthCodeStore, storing data in the Hub database
|
||||
"""
|
||||
def fetch_by_code(self, code):
|
||||
"""
|
||||
Returns an AuthorizationCode fetched from a storage.
|
||||
|
||||
:param code: The authorization code.
|
||||
:return: An instance of :class:`oauth2.datatype.AuthorizationCode`.
|
||||
:raises: :class:`oauth2.error.AuthCodeNotFound` if no data could be retrieved for
|
||||
given code.
|
||||
|
||||
"""
|
||||
orm_code = (
|
||||
self.db
|
||||
.query(orm.OAuthCode)
|
||||
.filter_by(code=code)
|
||||
.first()
|
||||
)
|
||||
if orm_code is None:
|
||||
raise AuthCodeNotFound()
|
||||
else:
|
||||
return AuthorizationCode(
|
||||
client_id=orm_code.client_id,
|
||||
code=code,
|
||||
expires_at=orm_code.expires_at,
|
||||
redirect_uri=orm_code.redirect_uri,
|
||||
scopes=[],
|
||||
user_id=orm_code.user_id,
|
||||
data={'session_id': orm_code.session_id},
|
||||
)
|
||||
|
||||
def save_code(self, authorization_code):
|
||||
"""
|
||||
Stores the data belonging to an authorization code token.
|
||||
|
||||
:param authorization_code: An instance of
|
||||
:class:`oauth2.datatype.AuthorizationCode`.
|
||||
"""
|
||||
orm_client = (
|
||||
self.db
|
||||
.query(orm.OAuthClient)
|
||||
.filter_by(identifier=authorization_code.client_id)
|
||||
.first()
|
||||
)
|
||||
if orm_client is None:
|
||||
raise ValueError("No such client: %s" % authorization_code.client_id)
|
||||
|
||||
orm_user = (
|
||||
self.db
|
||||
.query(orm.User)
|
||||
.filter_by(id=authorization_code.user_id)
|
||||
.first()
|
||||
)
|
||||
if orm_user is None:
|
||||
raise ValueError("No such user: %s" % authorization_code.user_id)
|
||||
|
||||
orm_code = orm.OAuthCode(
|
||||
client=orm_client,
|
||||
code=authorization_code.code,
|
||||
expires_at=authorization_code.expires_at,
|
||||
user=orm_user,
|
||||
redirect_uri=authorization_code.redirect_uri,
|
||||
session_id=authorization_code.data.get('session_id', ''),
|
||||
)
|
||||
self.db.add(orm_code)
|
||||
self.db.commit()
|
||||
|
||||
|
||||
def delete_code(self, code):
|
||||
"""
|
||||
Deletes an authorization code after its use per section 4.1.2.
|
||||
|
||||
http://tools.ietf.org/html/rfc6749#section-4.1.2
|
||||
|
||||
:param code: The authorization code.
|
||||
"""
|
||||
orm_code = self.db.query(orm.OAuthCode).filter_by(code=code).first()
|
||||
if orm_code is not None:
|
||||
self.db.delete(orm_code)
|
||||
self.db.commit()
|
||||
|
||||
|
||||
class HashComparable:
|
||||
"""An object for storing hashed tokens
|
||||
|
||||
Overrides `==` so that it compares as equal to its unhashed original
|
||||
|
||||
Needed for storing hashed client_secrets
|
||||
because python-oauth2 uses::
|
||||
|
||||
secret == client.client_secret
|
||||
|
||||
and we don't want to store unhashed secrets in the database.
|
||||
"""
|
||||
def __init__(self, hashed_token):
|
||||
self.hashed_token = hashed_token
|
||||
|
||||
def __repr__(self):
|
||||
return "<{} '{}'>".format(self.__class__.__name__, self.hashed_token)
|
||||
|
||||
def __eq__(self, other):
|
||||
return compare_token(self.hashed_token, other)
|
||||
|
||||
|
||||
class ClientStore(HubDBMixin, oauth2.store.ClientStore):
|
||||
"""OAuth2 ClientStore, storing data in the Hub database"""
|
||||
|
||||
def fetch_by_client_id(self, client_id):
|
||||
"""Retrieve a client by its identifier.
|
||||
|
||||
:param client_id: Identifier of a client app.
|
||||
:return: An instance of :class:`oauth2.datatype.Client`.
|
||||
:raises: :class:`oauth2.error.ClientNotFoundError` if no data could be retrieved for
|
||||
given client_id.
|
||||
"""
|
||||
orm_client = (
|
||||
self.db
|
||||
.query(orm.OAuthClient)
|
||||
.filter_by(identifier=client_id)
|
||||
.first()
|
||||
)
|
||||
if orm_client is None:
|
||||
raise ClientNotFoundError()
|
||||
return Client(identifier=client_id,
|
||||
redirect_uris=[orm_client.redirect_uri],
|
||||
secret=HashComparable(orm_client.secret),
|
||||
)
|
||||
|
||||
def add_client(self, client_id, client_secret, redirect_uri, description=''):
|
||||
"""Add a client
|
||||
|
||||
hash its client_secret before putting it in the database.
|
||||
"""
|
||||
# clear existing clients with same ID
|
||||
for orm_client in (
|
||||
self.db
|
||||
.query(orm.OAuthClient)\
|
||||
.filter_by(identifier=client_id)
|
||||
):
|
||||
self.db.delete(orm_client)
|
||||
self.db.commit()
|
||||
|
||||
orm_client = orm.OAuthClient(
|
||||
identifier=client_id,
|
||||
secret=hash_token(client_secret),
|
||||
redirect_uri=redirect_uri,
|
||||
description=description,
|
||||
)
|
||||
self.db.add(orm_client)
|
||||
self.db.commit()
|
||||
|
||||
|
||||
def make_provider(session_factory, url_prefix, login_url):
|
||||
"""Make an OAuth provider"""
|
||||
token_store = AccessTokenStore(session_factory)
|
||||
code_store = AuthCodeStore(session_factory)
|
||||
client_store = ClientStore(session_factory)
|
||||
|
||||
provider = Provider(
|
||||
access_token_store=token_store,
|
||||
auth_code_store=code_store,
|
||||
client_store=client_store,
|
||||
token_generator=UUID4(),
|
||||
)
|
||||
provider.token_path = url_path_join(url_prefix, 'token')
|
||||
provider.authorize_path = url_path_join(url_prefix, 'authorize')
|
||||
site_adapter = JupyterHubSiteAdapter(login_url=login_url)
|
||||
provider.add_grant(AuthorizationCodeGrant(site_adapter=site_adapter))
|
||||
return provider
|
||||
|
@@ -469,6 +469,7 @@ class OAuthAccessToken(Hashed, Base):
|
||||
grant_type = Column(Enum(GrantType), nullable=False)
|
||||
expires_at = Column(Integer)
|
||||
refresh_token = Column(Unicode(255))
|
||||
# TODO: drop refresh_expires_at. Refresh tokens shouldn't expire
|
||||
refresh_expires_at = Column(Integer)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
|
||||
service = None # for API-equivalence with APIToken
|
||||
@@ -513,6 +514,7 @@ class OAuthCode(Base):
|
||||
expires_at = Column(Integer)
|
||||
redirect_uri = Column(Unicode(1023))
|
||||
session_id = Column(Unicode(255))
|
||||
# state = Column(Unicode(1023))
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
|
||||
|
||||
|
||||
@@ -524,6 +526,10 @@ class OAuthClient(Base):
|
||||
secret = Column(Unicode(255))
|
||||
redirect_uri = Column(Unicode(1023))
|
||||
|
||||
@property
|
||||
def client_id(self):
|
||||
return self.identifier
|
||||
|
||||
access_tokens = relationship(
|
||||
OAuthAccessToken,
|
||||
backref='client',
|
||||
|
@@ -302,7 +302,15 @@ class HubAuth(SingletonConfigurable):
|
||||
elif r.status_code >= 400:
|
||||
app_log.warning("Failed to check authorization: [%i] %s", r.status_code, r.reason)
|
||||
app_log.warning(r.text)
|
||||
raise HTTPError(500, "Failed to check authorization")
|
||||
msg = "Failed to check authorization"
|
||||
# pass on error_description from oauth failure
|
||||
try:
|
||||
description = r.json().get("error_description")
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
msg += ": " + description
|
||||
raise HTTPError(500, msg)
|
||||
else:
|
||||
data = r.json()
|
||||
|
||||
@@ -847,6 +855,11 @@ class HubOAuthCallbackHandler(HubOAuthenticated, RequestHandler):
|
||||
|
||||
@coroutine
|
||||
def get(self):
|
||||
error = self.get_argument("error", False)
|
||||
if error:
|
||||
msg = self.get_argument("error_description", error)
|
||||
raise HTTPError(400, "Error in oauth: %s" % msg)
|
||||
|
||||
code = self.get_argument("code", False)
|
||||
if not code:
|
||||
raise HTTPError(400, "oauth callback made without a token")
|
||||
|
@@ -600,6 +600,21 @@ def test_announcements(app, announcements):
|
||||
assert_announcement("logout", r.text)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"params",
|
||||
[
|
||||
"",
|
||||
"redirect_uri=/noexist",
|
||||
"redirect_uri=ok&client_id=nosuchthing",
|
||||
]
|
||||
)
|
||||
@pytest.mark.gen_test
|
||||
def test_bad_oauth_get(app, params):
|
||||
cookies = yield app.login_user("authorizer")
|
||||
r = yield get_page("hub/api/oauth2/authorize?" + params, app, hub=False, cookies=cookies)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.gen_test
|
||||
def test_token_page(app):
|
||||
name = "cake"
|
||||
|
@@ -1,6 +1,8 @@
|
||||
"""Tests for service authentication"""
|
||||
import asyncio
|
||||
from binascii import hexlify
|
||||
import copy
|
||||
from functools import partial
|
||||
import json
|
||||
import os
|
||||
from queue import Queue
|
||||
@@ -24,7 +26,7 @@ from ..services.auth import _ExpiringDict, HubAuth, HubAuthenticated
|
||||
from ..utils import url_path_join
|
||||
from .mocking import public_url, public_host
|
||||
from .test_api import add_user
|
||||
from .utils import async_requests
|
||||
from .utils import async_requests, AsyncSession
|
||||
|
||||
# mock for sending monotonic counter way into the future
|
||||
monotonic_future = mock.patch('time.monotonic', lambda : sys.maxsize)
|
||||
@@ -322,24 +324,29 @@ def test_hubauth_service_token(app, mockservice_url):
|
||||
def test_oauth_service(app, mockservice_url):
|
||||
service = mockservice_url
|
||||
url = url_path_join(public_url(app, mockservice_url) + 'owhoami/?arg=x')
|
||||
# first request is only going to set login cookie
|
||||
s = requests.Session()
|
||||
# first request is only going to login and get us to the oauth form page
|
||||
s = AsyncSession()
|
||||
name = 'link'
|
||||
s.cookies = yield app.login_user(name)
|
||||
# run session.get in async_requests thread
|
||||
s_get = lambda *args, **kwargs: async_requests.executor.submit(s.get, *args, **kwargs)
|
||||
r = yield s_get(url)
|
||||
|
||||
r = yield s.get(url)
|
||||
r.raise_for_status()
|
||||
# we should be looking at the oauth confirmation page
|
||||
assert urlparse(r.url).path == app.base_url + 'hub/api/oauth2/authorize'
|
||||
# verify oauth state cookie was set at some point
|
||||
assert set(r.history[0].cookies.keys()) == {'service-%s-oauth-state' % service.name}
|
||||
|
||||
# submit the oauth form to complete authorization
|
||||
r = yield s.post(r.url, data={'scopes': ['identify']}, headers={'Referer': r.url})
|
||||
r.raise_for_status()
|
||||
assert r.url == url
|
||||
# verify oauth cookie is set
|
||||
assert 'service-%s' % service.name in set(s.cookies.keys())
|
||||
# verify oauth state cookie has been consumed
|
||||
assert 'service-%s-oauth-state' % service.name not in set(s.cookies.keys())
|
||||
# verify oauth state cookie was set at some point
|
||||
assert set(r.history[0].cookies.keys()) == {'service-%s-oauth-state' % service.name}
|
||||
|
||||
# second request should be authenticated
|
||||
r = yield s_get(url, allow_redirects=False)
|
||||
# second request should be authenticated, which means no redirects
|
||||
r = yield s.get(url, allow_redirects=False)
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 200
|
||||
reply = r.json()
|
||||
@@ -376,25 +383,23 @@ def test_oauth_cookie_collision(app, mockservice_url):
|
||||
service = mockservice_url
|
||||
url = url_path_join(public_url(app, mockservice_url), 'owhoami/')
|
||||
print(url)
|
||||
s = requests.Session()
|
||||
s = AsyncSession()
|
||||
name = 'mypha'
|
||||
s.cookies = yield app.login_user(name)
|
||||
# run session.get in async_requests thread
|
||||
s_get = lambda *args, **kwargs: async_requests.executor.submit(s.get, *args, **kwargs)
|
||||
state_cookie_name = 'service-%s-oauth-state' % service.name
|
||||
service_cookie_name = 'service-%s' % service.name
|
||||
oauth_1 = yield s_get(url, allow_redirects=False)
|
||||
oauth_1 = yield s.get(url)
|
||||
print(oauth_1.headers)
|
||||
print(oauth_1.cookies, oauth_1.url, url)
|
||||
assert state_cookie_name in s.cookies
|
||||
state_cookies = [ s for s in s.cookies.keys() if s.startswith(state_cookie_name) ]
|
||||
state_cookies = [ c for c in s.cookies.keys() if c.startswith(state_cookie_name) ]
|
||||
# only one state cookie
|
||||
assert state_cookies == [state_cookie_name]
|
||||
state_1 = s.cookies[state_cookie_name]
|
||||
|
||||
# start second oauth login before finishing the first
|
||||
oauth_2 = yield s_get(url, allow_redirects=False)
|
||||
state_cookies = [ s for s in s.cookies.keys() if s.startswith(state_cookie_name) ]
|
||||
oauth_2 = yield s.get(url)
|
||||
state_cookies = [ c for c in s.cookies.keys() if c.startswith(state_cookie_name) ]
|
||||
assert len(state_cookies) == 2
|
||||
# get the random-suffix cookie name
|
||||
state_cookie_2 = sorted(state_cookies)[-1]
|
||||
@@ -402,11 +407,14 @@ def test_oauth_cookie_collision(app, mockservice_url):
|
||||
assert s.cookies[state_cookie_name] == state_1
|
||||
|
||||
# finish oauth 2
|
||||
url = oauth_2.headers['Location']
|
||||
if not urlparse(url).netloc:
|
||||
url = public_host(app) + url
|
||||
r = yield s_get(url)
|
||||
# submit the oauth form to complete authorization
|
||||
r = yield s.post(
|
||||
oauth_2.url,
|
||||
data={'scopes': ['identify']},
|
||||
headers={'Referer': oauth_2.url},
|
||||
)
|
||||
r.raise_for_status()
|
||||
assert r.url == url
|
||||
# after finishing, state cookie is cleared
|
||||
assert state_cookie_2 not in s.cookies
|
||||
# service login cookie is set
|
||||
@@ -414,11 +422,14 @@ def test_oauth_cookie_collision(app, mockservice_url):
|
||||
service_cookie_2 = s.cookies[service_cookie_name]
|
||||
|
||||
# finish oauth 1
|
||||
url = oauth_1.headers['Location']
|
||||
if not urlparse(url).netloc:
|
||||
url = public_host(app) + url
|
||||
r = yield s_get(url)
|
||||
r = yield s.post(
|
||||
oauth_1.url,
|
||||
data={'scopes': ['identify']},
|
||||
headers={'Referer': oauth_1.url},
|
||||
)
|
||||
r.raise_for_status()
|
||||
assert r.url == url
|
||||
|
||||
# after finishing, state cookie is cleared (again)
|
||||
assert state_cookie_name not in s.cookies
|
||||
# service login cookie is set (again, to a different value)
|
||||
@@ -443,7 +454,7 @@ def test_oauth_logout(app, mockservice_url):
|
||||
service_cookie_name = 'service-%s' % service.name
|
||||
url = url_path_join(public_url(app, mockservice_url), 'owhoami/?foo=bar')
|
||||
# first request is only going to set login cookie
|
||||
s = requests.Session()
|
||||
s = AsyncSession()
|
||||
name = 'propha'
|
||||
app_user = add_user(app.db, app=app, name=name)
|
||||
def auth_tokens():
|
||||
@@ -458,13 +469,16 @@ def test_oauth_logout(app, mockservice_url):
|
||||
|
||||
s.cookies = yield app.login_user(name)
|
||||
assert 'jupyterhub-session-id' in s.cookies
|
||||
# run session.get in async_requests thread
|
||||
s_get = lambda *args, **kwargs: async_requests.executor.submit(s.get, *args, **kwargs)
|
||||
r = yield s_get(url)
|
||||
r = yield s.get(url)
|
||||
r.raise_for_status()
|
||||
assert urlparse(r.url).path.endswith('oauth2/authorize')
|
||||
# submit the oauth form to complete authorization
|
||||
r = yield s.post(r.url, data={'scopes': ['identify']}, headers={'Referer': r.url})
|
||||
r.raise_for_status()
|
||||
assert r.url == url
|
||||
|
||||
# second request should be authenticated
|
||||
r = yield s_get(url, allow_redirects=False)
|
||||
r = yield s.get(url, allow_redirects=False)
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 200
|
||||
reply = r.json()
|
||||
@@ -483,13 +497,13 @@ def test_oauth_logout(app, mockservice_url):
|
||||
assert len(auth_tokens()) == 1
|
||||
|
||||
# hit hub logout URL
|
||||
r = yield s_get(public_url(app, path='hub/logout'))
|
||||
r = yield s.get(public_url(app, path='hub/logout'))
|
||||
r.raise_for_status()
|
||||
# verify that all cookies other than the service cookie are cleared
|
||||
assert list(s.cookies.keys()) == [service_cookie_name]
|
||||
# verify that clearing session id invalidates service cookie
|
||||
# i.e. redirect back to login page
|
||||
r = yield s_get(url)
|
||||
r = yield s.get(url)
|
||||
r.raise_for_status()
|
||||
assert r.url.split('?')[0] == public_url(app, path='hub/login')
|
||||
|
||||
@@ -506,7 +520,7 @@ def test_oauth_logout(app, mockservice_url):
|
||||
# check that we got the old session id back
|
||||
assert session_id == s.cookies['jupyterhub-session-id']
|
||||
|
||||
r = yield s_get(url, allow_redirects=False)
|
||||
r = yield s.get(url, allow_redirects=False)
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 200
|
||||
reply = r.json()
|
||||
|
@@ -10,7 +10,7 @@ import jupyterhub
|
||||
from .mocking import StubSingleUserSpawner, public_url
|
||||
from ..utils import url_path_join
|
||||
|
||||
from .utils import async_requests
|
||||
from .utils import async_requests, AsyncSession
|
||||
|
||||
|
||||
@pytest.mark.gen_test
|
||||
@@ -41,9 +41,20 @@ def test_singleuser_auth(app):
|
||||
r = yield async_requests.get(url_path_join(url, 'logout'), cookies=cookies)
|
||||
assert len(r.cookies) == 0
|
||||
|
||||
# another user accessing should get 403, not redirect to login
|
||||
# accessing another user's server hits the oauth confirmation page
|
||||
cookies = yield app.login_user('burgess')
|
||||
r = yield async_requests.get(url, cookies=cookies)
|
||||
s = AsyncSession()
|
||||
s.cookies = cookies
|
||||
r = yield s.get(url)
|
||||
assert urlparse(r.url).path.endswith('/oauth2/authorize')
|
||||
# submit the oauth form to complete authorization
|
||||
r = yield s.post(
|
||||
r.url,
|
||||
data={'scopes': ['identify']},
|
||||
headers={'Referer': r.url},
|
||||
)
|
||||
assert urlparse(r.url).path.rstrip('/').endswith('/user/nandy/tree')
|
||||
# user isn't authorized, should raise 403
|
||||
assert r.status_code == 403
|
||||
assert 'burgess' in r.text
|
||||
|
||||
|
@@ -16,3 +16,7 @@ class _AsyncRequests:
|
||||
# async_requests.get = requests.get returning a Future, etc.
|
||||
async_requests = _AsyncRequests()
|
||||
|
||||
class AsyncSession(requests.Session):
|
||||
"""requests.Session object that runs in the background thread"""
|
||||
def request(self, *args, **kwargs):
|
||||
return async_requests.executor.submit(super().request, *args, **kwargs)
|
||||
|
@@ -6,7 +6,6 @@ from datetime import datetime, timedelta
|
||||
from urllib.parse import quote, urlparse
|
||||
import warnings
|
||||
|
||||
from oauth2.error import ClientNotFoundError
|
||||
from sqlalchemy import inspect
|
||||
from tornado import gen
|
||||
from tornado.log import app_log
|
||||
@@ -375,17 +374,14 @@ class User:
|
||||
client_id = spawner.oauth_client_id
|
||||
oauth_provider = self.settings.get('oauth_provider')
|
||||
if oauth_provider:
|
||||
client_store = oauth_provider.client_authenticator.client_store
|
||||
try:
|
||||
oauth_client = client_store.fetch_by_client_id(client_id)
|
||||
except ClientNotFoundError:
|
||||
oauth_client = None
|
||||
oauth_client = oauth_provider.fetch_by_client_id(client_id)
|
||||
# create a new OAuth client + secret on every launch
|
||||
# containers that resume will be updated below
|
||||
client_store.add_client(client_id, api_token,
|
||||
url_path_join(self.url, server_name, 'oauth_callback'),
|
||||
description="Server at %s" % (url_path_join(self.base_url, server_name) + '/'),
|
||||
)
|
||||
oauth_provider.add_client(
|
||||
client_id, api_token,
|
||||
url_path_join(self.url, server_name, 'oauth_callback'),
|
||||
description="Server at %s" % (url_path_join(self.base_url, server_name) + '/'),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# trigger pre-spawn hook on authenticator
|
||||
@@ -459,10 +455,10 @@ class User:
|
||||
)
|
||||
# update OAuth client secret with updated API token
|
||||
if oauth_provider:
|
||||
client_store = oauth_provider.client_authenticator.client_store
|
||||
client_store.add_client(client_id, spawner.api_token,
|
||||
url_path_join(self.url, server_name, 'oauth_callback'),
|
||||
)
|
||||
oauth_provider.add_client(
|
||||
client_id, spawner.api_token,
|
||||
url_path_join(self.url, server_name, 'oauth_callback'),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
except Exception as e:
|
||||
|
@@ -4,7 +4,7 @@ traitlets>=4.3.2
|
||||
tornado>=5.0
|
||||
jinja2
|
||||
pamela
|
||||
python-oauth2>=1.0
|
||||
oauthlib>=2.0
|
||||
python-dateutil
|
||||
SQLAlchemy>=1.1
|
||||
requests
|
||||
|
51
share/jupyterhub/templates/oauth.html
Normal file
51
share/jupyterhub/templates/oauth.html
Normal file
@@ -0,0 +1,51 @@
|
||||
{% extends "page.html" %}
|
||||
|
||||
{% block login_widget %}
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="container col-md-6 col-md-offset-3">
|
||||
<h1 class="text-center">Authorize access</h1>
|
||||
|
||||
<h2>
|
||||
A service is attempting to authorize with your
|
||||
JupyterHub account
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
{{ oauth_client.description }} (oauth URL: {{ oauth_client.redirect_uri }})
|
||||
would like permission to identify you.
|
||||
{% if scopes == ["identify"] %}
|
||||
It will not be able to take actions on your behalf.
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<h3>The application will be able to:</h3>
|
||||
<div>
|
||||
<form method="POST" action="">
|
||||
{% for scope in scopes %}
|
||||
<div class="checkbox input-group">
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
name="scopes"
|
||||
checked="true"
|
||||
title="This authorization is required"
|
||||
disabled="disabled" {# disabled because it's required #}
|
||||
value="{{ scope }}"
|
||||
/>
|
||||
{# disabled checkbox isn't included in form, so this is the real one #}
|
||||
<input type="hidden" name="scopes" value="{{ scope }}"/>
|
||||
<span>
|
||||
{# TODO: use scope description when there's more than one #}
|
||||
See your JupyterHub username and group membership (read-only).
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<input type="submit" value="Authorize" class="form-control btn-jupyter"/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
Reference in New Issue
Block a user