mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-13 13:03:01 +00:00
avoid oauth state cookie collisions
in case of multiple simultaneous - state arg is strictly required now - default cookie name in case of no collision is unchanged - in case of collision, randomize cookie name with a suffix and store cookie_name in state - expire state cookies after 10 minutes, not 1 day
This commit is contained in:
@@ -59,7 +59,7 @@ def oauth_callback():
|
|||||||
# validate state field
|
# validate state field
|
||||||
arg_state = request.args.get('state', None)
|
arg_state = request.args.get('state', None)
|
||||||
cookie_state = request.cookies.get(auth.state_cookie_name)
|
cookie_state = request.cookies.get(auth.state_cookie_name)
|
||||||
if arg_state != cookie_state:
|
if arg_state is None or arg_state != cookie_state:
|
||||||
# state doesn't match
|
# state doesn't match
|
||||||
return 403
|
return 403
|
||||||
|
|
||||||
|
@@ -13,8 +13,10 @@ authenticate with the Hub.
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
import re
|
import re
|
||||||
import socket
|
import socket
|
||||||
|
import string
|
||||||
import time
|
import time
|
||||||
from urllib.parse import quote, urlencode
|
from urllib.parse import quote, urlencode
|
||||||
import uuid
|
import uuid
|
||||||
@@ -531,22 +533,37 @@ class HubOAuth(HubAuth):
|
|||||||
-------
|
-------
|
||||||
state (str): The OAuth state that has been stored in the cookie (url safe, base64-encoded)
|
state (str): The OAuth state that has been stored in the cookie (url safe, base64-encoded)
|
||||||
"""
|
"""
|
||||||
b64_state = self.generate_state(next_url)
|
extra_state = {}
|
||||||
|
if handler.get_cookie(self.state_cookie_name):
|
||||||
|
# oauth state cookie is already set
|
||||||
|
# use a randomized cookie suffix to avoid collisions
|
||||||
|
# in case of concurrent logins
|
||||||
|
app_log.warning("Detected unused OAuth state cookies")
|
||||||
|
cookie_suffix = ''.join(random.choice(string.ascii_letters) for i in range(8))
|
||||||
|
cookie_name = '{}-{}'.format(self.state_cookie_name, cookie_suffix)
|
||||||
|
extra_state['cookie_name'] = cookie_name
|
||||||
|
else:
|
||||||
|
cookie_name = self.state_cookie_name
|
||||||
|
b64_state = self.generate_state(next_url, **extra_state)
|
||||||
kwargs = {
|
kwargs = {
|
||||||
'path': self.base_url,
|
'path': self.base_url,
|
||||||
'httponly': True,
|
'httponly': True,
|
||||||
'expires_days': 1,
|
# Expire oauth state cookie in ten minutes.
|
||||||
|
# Usually this will be cleared by completed login
|
||||||
|
# in less than a few seconds.
|
||||||
|
# OAuth that doesn't complete shouldn't linger too long.
|
||||||
|
'max_age': 600,
|
||||||
}
|
}
|
||||||
if handler.request.protocol == 'https':
|
if handler.request.protocol == 'https':
|
||||||
kwargs['secure'] = True
|
kwargs['secure'] = True
|
||||||
handler.set_secure_cookie(
|
handler.set_secure_cookie(
|
||||||
self.state_cookie_name,
|
cookie_name,
|
||||||
b64_state,
|
b64_state,
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
return b64_state
|
return b64_state
|
||||||
|
|
||||||
def generate_state(self, next_url=None):
|
def generate_state(self, next_url=None, **extra_state):
|
||||||
"""Generate a state string, given a next_url redirect target
|
"""Generate a state string, given a next_url redirect target
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
@@ -557,16 +574,27 @@ class HubOAuth(HubAuth):
|
|||||||
-------
|
-------
|
||||||
state (str): The base64-encoded state string.
|
state (str): The base64-encoded state string.
|
||||||
"""
|
"""
|
||||||
return self._encode_state({
|
state = {
|
||||||
'uuid': uuid.uuid4().hex,
|
'uuid': uuid.uuid4().hex,
|
||||||
'next_url': next_url
|
'next_url': next_url,
|
||||||
})
|
}
|
||||||
|
state.update(extra_state)
|
||||||
|
return self._encode_state(state)
|
||||||
|
|
||||||
def get_next_url(self, b64_state=''):
|
def get_next_url(self, b64_state=''):
|
||||||
"""Get the next_url for redirection, given an encoded OAuth state"""
|
"""Get the next_url for redirection, given an encoded OAuth state"""
|
||||||
state = self._decode_state(b64_state)
|
state = self._decode_state(b64_state)
|
||||||
return state.get('next_url') or self.base_url
|
return state.get('next_url') or self.base_url
|
||||||
|
|
||||||
|
def get_state_cookie_name(self, b64_state=''):
|
||||||
|
"""Get the cookie name for oauth state, given an encoded OAuth state
|
||||||
|
|
||||||
|
Cookie name is stored in the state itself because the cookie name
|
||||||
|
is randomized to deal with races between concurrent oauth sequences.
|
||||||
|
"""
|
||||||
|
state = self._decode_state(b64_state)
|
||||||
|
return state.get('cookie_name') or self.state_cookie_name
|
||||||
|
|
||||||
def set_cookie(self, handler, access_token):
|
def set_cookie(self, handler, access_token):
|
||||||
"""Set a cookie recording OAuth result"""
|
"""Set a cookie recording OAuth result"""
|
||||||
kwargs = {
|
kwargs = {
|
||||||
@@ -769,18 +797,19 @@ class HubOAuthCallbackHandler(HubOAuthenticated, RequestHandler):
|
|||||||
|
|
||||||
# validate OAuth state
|
# validate OAuth state
|
||||||
arg_state = self.get_argument("state", None)
|
arg_state = self.get_argument("state", None)
|
||||||
cookie_state = self.get_secure_cookie(self.hub_auth.state_cookie_name)
|
if arg_state is None:
|
||||||
next_url = None
|
raise HTTPError("oauth state is missing. Try logging in again.")
|
||||||
if arg_state or cookie_state:
|
cookie_name = self.hub_auth.get_state_cookie_name(arg_state)
|
||||||
# clear cookie state now that we've consumed it
|
cookie_state = self.get_secure_cookie(cookie_name)
|
||||||
self.clear_cookie(self.hub_auth.state_cookie_name, path=self.hub_auth.base_url)
|
# clear cookie state now that we've consumed it
|
||||||
if isinstance(cookie_state, bytes):
|
self.clear_cookie(cookie_name, path=self.hub_auth.base_url)
|
||||||
cookie_state = cookie_state.decode('ascii', 'replace')
|
if isinstance(cookie_state, bytes):
|
||||||
# check that state matches
|
cookie_state = cookie_state.decode('ascii', 'replace')
|
||||||
if arg_state != cookie_state:
|
# check that state matches
|
||||||
app_log.debug("oauth state %r != %r", arg_state, cookie_state)
|
if arg_state != cookie_state:
|
||||||
raise HTTPError(403, "oauth state does not match")
|
app_log.warning("oauth state %r != %r", arg_state, cookie_state)
|
||||||
next_url = self.hub_auth.get_next_url(cookie_state)
|
raise HTTPError(403, "oauth state does not match. Try logging in again.")
|
||||||
|
next_url = self.hub_auth.get_next_url(cookie_state)
|
||||||
# TODO: make async (in a Thread?)
|
# TODO: make async (in a Thread?)
|
||||||
token = self.hub_auth.token_for_code(code)
|
token = self.hub_auth.token_for_code(code)
|
||||||
user_model = self.hub_auth.user_for_token(token)
|
user_model = self.hub_auth.user_for_token(token)
|
||||||
|
Reference in New Issue
Block a user