diff --git a/jupyterhub/apihandlers/auth.py b/jupyterhub/apihandlers/auth.py index aa395edd..b8b3b727 100644 --- a/jupyterhub/apihandlers/auth.py +++ b/jupyterhub/apihandlers/auth.py @@ -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): - pass + @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('

Authorize access to %s

' % client_id) + self.write('
') + for scope in scopes or []: + self.write(' %s' % (scope, scope)) + self.write('') + + # 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), ] diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 55683634..fdfb72fe 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -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) diff --git a/jupyterhub/oauth/store.py b/jupyterhub/oauth/store.py index 97ca345f..22798c8a 100644 --- a/jupyterhub/oauth/store.py +++ b/jupyterhub/oauth/store.py @@ -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): - """ - OAuth2 AuthCodeStore, storing data in the Hub database - """ - def fetch_by_code(self, code): + 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 """ - 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 diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index 5da742b8..fc28bf77 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -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', diff --git a/jupyterhub/user.py b/jupyterhub/user.py index f65affc4..dae049fe 100644 --- a/jupyterhub/user.py +++ b/jupyterhub/user.py @@ -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,17 +371,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 @@ -456,10 +452,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: diff --git a/requirements.txt b/requirements.txt index 33ce10b8..ec8fe23f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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