mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-13 04:53:01 +00:00
[wip] switch to oauthlib from python-oauth2
lower-level implementation, but more robust and gives us more control
This commit is contained in:
@@ -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,149 @@ class CookieAPIHandler(APIHandler):
|
||||
self.write(json.dumps(self.user_model(user)))
|
||||
|
||||
|
||||
class OAuthHandler(BaseHandler, OAuth2Handler):
|
||||
class OAuthHandler(BaseHandler):
|
||||
def extract_oauth_params(self):
|
||||
"""extract oauthlib params from a request
|
||||
|
||||
Returns:
|
||||
|
||||
(uri, http_method, body, headers)
|
||||
"""
|
||||
return (
|
||||
self.make_absolute_redirect_uri(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.
|
||||
"""
|
||||
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))
|
||||
)
|
||||
|
||||
|
||||
class OAuthAuthorizeHandler(OAuthHandler):
|
||||
"""Implement OAuth provider handlers
|
||||
|
||||
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']
|
||||
|
||||
def initialize(self):
|
||||
@web.authenticated
|
||||
def get(self):
|
||||
# You need to define extract_params and make sure it does not
|
||||
# include file like objects waiting for input. In Django this
|
||||
# is request.META['wsgi.input'] and request.META['wsgi.errors']
|
||||
uri, http_method, body, headers = self.extract_oauth_params()
|
||||
|
||||
try:
|
||||
scopes, credentials = self.oauth_provider.validate_authorization_request(
|
||||
uri, http_method, body, headers)
|
||||
|
||||
if scopes == ['identify']:
|
||||
pass
|
||||
client_id = 'hmmm'
|
||||
# You probably want to render a template instead.
|
||||
self.write('<h1> Authorize access to %s </h1>' % client_id)
|
||||
self.write('<form method="POST" action="">')
|
||||
for scope in scopes or []:
|
||||
self.write('<input type="checkbox" name="scopes" ' +
|
||||
'value="%s"/> %s' % (scope, scope))
|
||||
self.write('<input type="submit" value="Authorize"/>')
|
||||
|
||||
# Errors that should be shown to the user on the provider website
|
||||
except oauth2.FatalClientError as e:
|
||||
# TODO: human error page
|
||||
raise
|
||||
# return response_from_error(e)
|
||||
|
||||
# Errors embedded in the redirect URI back to the client
|
||||
except oauth2.OAuth2Error as e:
|
||||
self.log.error("oauth error: %s" % e)
|
||||
self.redirect(e.in_uri(e.redirect_uri))
|
||||
|
||||
@web.authenticated
|
||||
def post(self):
|
||||
uri, http_method, body, headers = self.extract_oauth_params()
|
||||
|
||||
# The scopes the user actually authorized, i.e. checkboxes
|
||||
# that were selected.
|
||||
scopes = self.get_arguments('scopes')
|
||||
|
||||
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 = {
|
||||
'user': user,
|
||||
'handler': self,
|
||||
'session_id': session_id,
|
||||
}
|
||||
|
||||
# The previously stored (in authorization GET view) credentials
|
||||
# credentials.update(request.session.get('oauth2_credentials', {}))
|
||||
|
||||
try:
|
||||
headers, body, status = self.oauth_provider.create_authorization_response(
|
||||
uri, http_method, body, headers, scopes, credentials)
|
||||
self.set_status(status)
|
||||
for key, value in headers.items():
|
||||
self.set_header(key, value)
|
||||
if body:
|
||||
self.write(body)
|
||||
|
||||
except oauth2.FatalClientError as e:
|
||||
# TODO: human error page
|
||||
raise
|
||||
|
||||
|
||||
class OAuthTokenHandler(OAuthHandler):
|
||||
|
||||
# get JSON error messages
|
||||
write_error = APIHandler.write_error
|
||||
|
||||
def post(self):
|
||||
uri, http_method, body, headers = self.extract_oauth_params()
|
||||
credentials = {}
|
||||
|
||||
headers, body, status = self.oauth_provider.create_token_response(
|
||||
uri, http_method, body, headers, credentials)
|
||||
|
||||
self.set_status(status)
|
||||
for key, value in headers.items():
|
||||
self.set_header(key, value)
|
||||
if body:
|
||||
self.write(body)
|
||||
|
||||
|
||||
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),
|
||||
]
|
||||
|
@@ -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)
|
||||
|
@@ -3,104 +3,412 @@
|
||||
implements https://python-oauth2.readthedocs.io/en/latest/store.html
|
||||
"""
|
||||
|
||||
import threading
|
||||
from datetime import datetime
|
||||
|
||||
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 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
|
||||
|
||||
|
||||
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
|
||||
class JupyterHubRequestValidator(RequestValidator):
|
||||
|
||||
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)
|
||||
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()
|
||||
)
|
||||
return response
|
||||
if oauth_client is None:
|
||||
raise web.HTTPError(400, "Bad OAuth Client")
|
||||
if not compare_token(oauth_client.secret, client_secret):
|
||||
raise web.HTTPError(400, "Bad OAuth Client")
|
||||
|
||||
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()
|
||||
request.client = oauth_client
|
||||
return True
|
||||
|
||||
def user_has_denied_access(self, request):
|
||||
# user can't deny access
|
||||
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:
|
||||
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)
|
||||
"""
|
||||
app_log.debug("confirm_redirect_uri: client_id=%s, code=%s, redirect_uri=%s",
|
||||
client_id, code, redirect_uri,
|
||||
)
|
||||
orm_client = (
|
||||
self.db
|
||||
.query(orm.OAuthClient)
|
||||
.filter_by(identifier=client_id)
|
||||
.first()
|
||||
)
|
||||
if orm_client is None:
|
||||
return False
|
||||
# TODO: confirm redirect uri
|
||||
return True
|
||||
|
||||
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:
|
||||
return False
|
||||
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
|
||||
"""
|
||||
token = find_token()
|
||||
return token.scopes
|
||||
|
||||
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
|
||||
"""
|
||||
return set(request_scopes)
|
||||
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):
|
||||
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
|
||||
"""
|
||||
Stores an access token in the database.
|
||||
|
||||
:param access_token: An instance of :class:`oauth2.datatype.AccessToken`.
|
||||
app_log.debug("Deleting oauth code %s for %s", code, 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.')
|
||||
|
||||
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()
|
||||
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
|
||||
"""
|
||||
app_log.debug("Saving authorization code %s, %s, %s, %s", client_id, 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),
|
||||
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
|
||||
"""
|
||||
return []
|
||||
|
||||
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
|
||||
"""
|
||||
app_log.debug("Saving bearer token %s", 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=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,
|
||||
grant_type=orm.GrantType.authorization_code,
|
||||
expires_at=datetime.utcnow().timestamp() + token['expires_in'],
|
||||
refresh_token=token['refresh_token'],
|
||||
# refresh_expires_at=access_token.refresh_expires_at,
|
||||
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
|
||||
|
||||
|
||||
class AuthCodeStore(HubDBMixin, oauth2.store.AuthCodeStore):
|
||||
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
|
||||
"""
|
||||
OAuth2 AuthCodeStore, storing data in the Hub database
|
||||
"""
|
||||
def fetch_by_code(self, code):
|
||||
"""
|
||||
Returns an AuthorizationCode fetched from a storage.
|
||||
raise NotImplementedError('Subclasses must implement this method.')
|
||||
|
||||
: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.
|
||||
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
|
||||
@@ -109,68 +417,95 @@ class AuthCodeStore(HubDBMixin, oauth2.store.AuthCodeStore):
|
||||
.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},
|
||||
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 save_code(self, authorization_code):
|
||||
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
|
||||
"""
|
||||
Stores the data belonging to an authorization code token.
|
||||
return True
|
||||
|
||||
:param authorization_code: An instance of
|
||||
:class:`oauth2.datatype.AuthorizationCode`.
|
||||
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
|
||||
"""
|
||||
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)
|
||||
return True
|
||||
raise NotImplementedError('Subclasses must implement this method.')
|
||||
|
||||
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):
|
||||
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
|
||||
"""
|
||||
Deletes an authorization code after its use per section 4.1.2.
|
||||
return False
|
||||
raise NotImplementedError('Subclasses must implement this method.')
|
||||
|
||||
http://tools.ietf.org/html/rfc6749#section-4.1.2
|
||||
|
||||
:param code: The authorization code.
|
||||
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
|
||||
"""
|
||||
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()
|
||||
# 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 HashComparable:
|
||||
"""An object for storing hashed tokens
|
||||
@@ -194,29 +529,10 @@ class HashComparable:
|
||||
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),
|
||||
)
|
||||
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
|
||||
@@ -241,22 +557,20 @@ class ClientStore(HubDBMixin, oauth2.store.ClientStore):
|
||||
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"""
|
||||
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
|
||||
db = session_factory()
|
||||
validator = JupyterHubRequestValidator(db)
|
||||
server = JupyterHubOAuthServer(db, validator)
|
||||
return server
|
||||
|
||||
|
@@ -513,6 +513,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 +525,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',
|
||||
|
@@ -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
|
||||
@@ -372,14 +371,11 @@ 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,
|
||||
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) + '/'),
|
||||
)
|
||||
@@ -456,8 +452,8 @@ 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,
|
||||
oauth_provider.add_client(
|
||||
client_id, spawner.api_token,
|
||||
url_path_join(self.url, server_name, 'oauth_callback'),
|
||||
)
|
||||
db.commit()
|
||||
|
@@ -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
|
||||
|
Reference in New Issue
Block a user