mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-15 22:13:00 +00:00
add session_id for OAuth tokens
allows tracking and revoking tokens for a login session
This commit is contained in:
28
jupyterhub/alembic/versions/1cebaf56856c_session_id.py
Normal file
28
jupyterhub/alembic/versions/1cebaf56856c_session_id.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""Add session_id to auth tokens
|
||||||
|
|
||||||
|
Revision ID: 1cebaf56856c
|
||||||
|
Revises: 3ec6993fe20c
|
||||||
|
Create Date: 2017-12-07 14:43:51.500740
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '1cebaf56856c'
|
||||||
|
down_revision = '3ec6993fe20c'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
tables = ('oauth_access_tokens', 'oauth_codes')
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
for table in tables:
|
||||||
|
op.add_column(table, sa.Column('session_id', sa.Unicode(255)))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# sqlite cannot downgrade because of limited ALTER TABLE support (no DROP COLUMN)
|
||||||
|
for table in tables:
|
||||||
|
op.drop_column(table, 'session_id')
|
@@ -8,6 +8,7 @@ import re
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from http.client import responses
|
from http.client import responses
|
||||||
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
|
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
|
||||||
|
import uuid
|
||||||
|
|
||||||
from jinja2 import TemplateNotFound
|
from jinja2 import TemplateNotFound
|
||||||
|
|
||||||
@@ -34,6 +35,9 @@ reasons = {
|
|||||||
'error': "Failed to start your server. Please contact admin.",
|
'error': "Failed to start your server. Please contact admin.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# constant, not configurable
|
||||||
|
SESSION_COOKIE_NAME = 'jupyterhub-session-id'
|
||||||
|
|
||||||
class BaseHandler(RequestHandler):
|
class BaseHandler(RequestHandler):
|
||||||
"""Base Handler class with access to common methods and properties."""
|
"""Base Handler class with access to common methods and properties."""
|
||||||
|
|
||||||
@@ -77,6 +81,7 @@ class BaseHandler(RequestHandler):
|
|||||||
@property
|
@property
|
||||||
def services(self):
|
def services(self):
|
||||||
return self.settings.setdefault('services', {})
|
return self.settings.setdefault('services', {})
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hub(self):
|
def hub(self):
|
||||||
return self.settings['hub']
|
return self.settings['hub']
|
||||||
@@ -255,10 +260,39 @@ class BaseHandler(RequestHandler):
|
|||||||
kwargs = {}
|
kwargs = {}
|
||||||
if self.subdomain_host:
|
if self.subdomain_host:
|
||||||
kwargs['domain'] = self.domain
|
kwargs['domain'] = self.domain
|
||||||
|
user = self.get_current_user_cookie()
|
||||||
|
session_id = self.get_session_cookie()
|
||||||
|
if session_id:
|
||||||
|
# clear session id
|
||||||
|
self.clear_cookie(SESSION_COOKIE_NAME)
|
||||||
|
|
||||||
|
if user:
|
||||||
|
# user is logged in, clear any tokens associated with the current session
|
||||||
|
# don't clear session tokens if not logged in,
|
||||||
|
# because that could be a malicious logout request!
|
||||||
|
count = 0
|
||||||
|
for access_token in (
|
||||||
|
self.db.query(orm.OAuthAccessToken)
|
||||||
|
.filter(orm.OAuthAccessToken.user_id==user.id)
|
||||||
|
.filter(orm.OAuthAccessToken.session_id==session_id)
|
||||||
|
):
|
||||||
|
self.db.delete(access_token)
|
||||||
|
count += 1
|
||||||
|
if count:
|
||||||
|
self.log.debug("Deleted %s access tokens for %s", count, user.name)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# clear hub cookie
|
||||||
self.clear_cookie(self.hub.cookie_name, path=self.hub.base_url, **kwargs)
|
self.clear_cookie(self.hub.cookie_name, path=self.hub.base_url, **kwargs)
|
||||||
self.clear_cookie('jupyterhub-services', path=url_path_join(self.base_url, 'services'))
|
self.clear_cookie('jupyterhub-services', path=url_path_join(self.base_url, 'services'))
|
||||||
|
|
||||||
def _set_user_cookie(self, user, server):
|
def _set_cookie(self, key, value, encrypted=True, **overrides):
|
||||||
|
"""Setting any cookie should go through here
|
||||||
|
|
||||||
|
if encrypted use tornado's set_secure_cookie,
|
||||||
|
otherwise set plaintext cookies.
|
||||||
|
"""
|
||||||
# tornado <4.2 have a bug that consider secure==True as soon as
|
# tornado <4.2 have a bug that consider secure==True as soon as
|
||||||
# 'secure' kwarg is passed to set_secure_cookie
|
# 'secure' kwarg is passed to set_secure_cookie
|
||||||
kwargs = {
|
kwargs = {
|
||||||
@@ -270,14 +304,45 @@ class BaseHandler(RequestHandler):
|
|||||||
kwargs['domain'] = self.domain
|
kwargs['domain'] = self.domain
|
||||||
|
|
||||||
kwargs.update(self.settings.get('cookie_options', {}))
|
kwargs.update(self.settings.get('cookie_options', {}))
|
||||||
self.log.debug("Setting cookie for %s: %s, %s", user.name, server.cookie_name, kwargs)
|
kwargs.update(overrides)
|
||||||
self.set_secure_cookie(
|
|
||||||
|
if encrypted:
|
||||||
|
set_cookie = self.set_secure_cookie
|
||||||
|
else:
|
||||||
|
set_cookie = self.set_cookie
|
||||||
|
|
||||||
|
self.log.debug("Setting cookie %s: %s", key, kwargs)
|
||||||
|
set_cookie(key, value, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_user_cookie(self, user, server):
|
||||||
|
self.log.debug("Setting cookie for %s: %s", user.name, server.cookie_name)
|
||||||
|
self._set_cookie(
|
||||||
server.cookie_name,
|
server.cookie_name,
|
||||||
user.cookie_id,
|
user.cookie_id,
|
||||||
|
encrypted=True,
|
||||||
path=server.base_url,
|
path=server.base_url,
|
||||||
**kwargs
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_session_cookie(self):
|
||||||
|
"""Get the session id from a cookie
|
||||||
|
|
||||||
|
Returns None if no session id is stored
|
||||||
|
"""
|
||||||
|
return self.get_cookie(SESSION_COOKIE_NAME, None)
|
||||||
|
|
||||||
|
def set_session_cookie(self):
|
||||||
|
"""Set a new session id cookie
|
||||||
|
|
||||||
|
new session id is returned
|
||||||
|
|
||||||
|
Session id cookie is *not* encrypted,
|
||||||
|
so other services on this domain can read it.
|
||||||
|
"""
|
||||||
|
session_id = uuid.uuid4().hex
|
||||||
|
self._set_cookie(SESSION_COOKIE_NAME, session_id, encrypted=False)
|
||||||
|
return session_id
|
||||||
|
|
||||||
def set_service_cookie(self, user):
|
def set_service_cookie(self, user):
|
||||||
"""set the login cookie for services"""
|
"""set the login cookie for services"""
|
||||||
self._set_user_cookie(user, orm.Server(
|
self._set_user_cookie(user, orm.Server(
|
||||||
@@ -300,6 +365,9 @@ class BaseHandler(RequestHandler):
|
|||||||
if self.db.query(orm.Service).filter(orm.Service.server != None).first():
|
if self.db.query(orm.Service).filter(orm.Service.server != None).first():
|
||||||
self.set_service_cookie(user)
|
self.set_service_cookie(user)
|
||||||
|
|
||||||
|
if not self.get_session_cookie():
|
||||||
|
self.set_session_cookie()
|
||||||
|
|
||||||
# create and set a new cookie token for the hub
|
# create and set a new cookie token for the hub
|
||||||
if not self.get_current_user_cookie():
|
if not self.get_current_user_cookie():
|
||||||
self.set_hub_cookie(user)
|
self.set_hub_cookie(user)
|
||||||
|
@@ -39,15 +39,19 @@ class JupyterHubSiteAdapter(AuthorizationCodeGrantSiteAdapter):
|
|||||||
def authenticate(self, request, environ, scopes, client):
|
def authenticate(self, request, environ, scopes, client):
|
||||||
handler = request.handler
|
handler = request.handler
|
||||||
user = handler.get_current_user()
|
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:
|
if user:
|
||||||
return {}, user.id
|
return {'session_id': session_id}, user.id
|
||||||
else:
|
else:
|
||||||
raise UserNotAuthenticated()
|
raise UserNotAuthenticated()
|
||||||
|
|
||||||
def user_has_denied_access(self, request):
|
def user_has_denied_access(self, request):
|
||||||
# user can't deny access
|
# user can't deny access
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
class HubDBMixin(object):
|
class HubDBMixin(object):
|
||||||
"""Mixin for connecting to the hub database"""
|
"""Mixin for connecting to the hub database"""
|
||||||
@@ -65,7 +69,7 @@ class AccessTokenStore(HubDBMixin, oauth2.store.AccessTokenStore):
|
|||||||
:param access_token: An instance of :class:`oauth2.datatype.AccessToken`.
|
:param access_token: An instance of :class:`oauth2.datatype.AccessToken`.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
user = self.db.query(orm.User).filter(orm.User.id == access_token.user_id).first()
|
user = self.db.query(orm.User).filter(orm.User.id == access_token.user_id).first()
|
||||||
if user is None:
|
if user is None:
|
||||||
raise ValueError("No user for access token: %s" % access_token.user_id)
|
raise ValueError("No user for access token: %s" % access_token.user_id)
|
||||||
@@ -76,6 +80,7 @@ class AccessTokenStore(HubDBMixin, oauth2.store.AccessTokenStore):
|
|||||||
refresh_token=access_token.refresh_token,
|
refresh_token=access_token.refresh_token,
|
||||||
refresh_expires_at=access_token.refresh_expires_at,
|
refresh_expires_at=access_token.refresh_expires_at,
|
||||||
token=access_token.token,
|
token=access_token.token,
|
||||||
|
session_id=access_token.data['session_id'],
|
||||||
user=user,
|
user=user,
|
||||||
)
|
)
|
||||||
self.db.add(orm_access_token)
|
self.db.add(orm_access_token)
|
||||||
@@ -110,6 +115,7 @@ class AuthCodeStore(HubDBMixin, oauth2.store.AuthCodeStore):
|
|||||||
redirect_uri=orm_code.redirect_uri,
|
redirect_uri=orm_code.redirect_uri,
|
||||||
scopes=[],
|
scopes=[],
|
||||||
user_id=orm_code.user_id,
|
user_id=orm_code.user_id,
|
||||||
|
data={'session_id': orm_code.session_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -126,6 +132,7 @@ class AuthCodeStore(HubDBMixin, oauth2.store.AuthCodeStore):
|
|||||||
expires_at=authorization_code.expires_at,
|
expires_at=authorization_code.expires_at,
|
||||||
user_id=authorization_code.user_id,
|
user_id=authorization_code.user_id,
|
||||||
redirect_uri=authorization_code.redirect_uri,
|
redirect_uri=authorization_code.redirect_uri,
|
||||||
|
session_id=authorization_code.data.get('session_id', ''),
|
||||||
)
|
)
|
||||||
self.db.add(orm_code)
|
self.db.add(orm_code)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
@@ -412,10 +412,13 @@ class OAuthAccessToken(Hashed, Base):
|
|||||||
user = relationship(User)
|
user = relationship(User)
|
||||||
service = None # for API-equivalence with APIToken
|
service = None # for API-equivalence with APIToken
|
||||||
|
|
||||||
|
# the browser session id associated with a given token
|
||||||
|
session_id = Column(Unicode(255))
|
||||||
|
|
||||||
# from Hashed
|
# from Hashed
|
||||||
hashed = Column(Unicode(255), unique=True)
|
hashed = Column(Unicode(255), unique=True)
|
||||||
prefix = Column(Unicode(16), index=True)
|
prefix = Column(Unicode(16), index=True)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "<{cls}('{prefix}...', user='{user}'>".format(
|
return "<{cls}('{prefix}...', user='{user}'>".format(
|
||||||
cls=self.__class__.__name__,
|
cls=self.__class__.__name__,
|
||||||
@@ -431,6 +434,7 @@ class OAuthCode(Base):
|
|||||||
code = Column(Unicode(36))
|
code = Column(Unicode(36))
|
||||||
expires_at = Column(Integer)
|
expires_at = Column(Integer)
|
||||||
redirect_uri = Column(Unicode(1023))
|
redirect_uri = Column(Unicode(1023))
|
||||||
|
session_id = Column(Unicode(255))
|
||||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
|
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
|
||||||
|
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user