Signed-off-by: Micke Nordin <kano@sunet.se>
This commit is contained in:
Micke Nordin 2024-01-10 13:45:44 +01:00
parent 52e67df244
commit 10d2ee8fb8
Signed by untrusted user: Micke
GPG key ID: 0DA0A7A5708FE257

View file

@ -11,241 +11,16 @@ hub:
storageClassName: csi-sc-cinderplugin storageClassName: csi-sc-cinderplugin
extraConfig: extraConfig:
oauthCode: | oauthCode: |
import json
import os
import socket
from collections.abc import Mapping
from functools import lru_cache
from urllib.parse import urlencode, urlparse
import requests
import tornado.options
import yaml
from jupyterhub.services.auth import HubAuthenticated
from jupyterhub.utils import url_path_join
from oauthenticator.generic import GenericOAuthenticator from oauthenticator.generic import GenericOAuthenticator
from tornado.httpclient import AsyncHTTPClient, HTTPRequest c.JupyterHub.authenticator_class = GenericOAuthenticator
from tornado.httpserver import HTTPServer c.GenericOAuthenticator.client_id = os.environ['NEXTCLOUD_CLIENT_ID']
from tornado.ioloop import IOLoop c.GenericOAuthenticator.client_secret = os.environ['NEXTCLOUD_CLIENT_ID']
from tornado.log import app_log c.GenericOAuthenticator.login_service = 'Sunet Drive'
from tornado.web import Application, HTTPError, RequestHandler, authenticated c.GenericOAuthenticator.username_key = lambda r: r.get('ocs', {}).get('data', {}).get('id')
c.GenericOAuthenticator.userdata_url = 'https://' + os.environ['NEXTCLOUD_HOST'] + '/ocs/v2.php/cloud/user?format=json'
c.GenericOAuthenticator.authorize_url = 'https://' + os.environ['NEXTCLOUD_HOST'] + '/index.php/apps/oauth2/authorize'
def post_auth_hook(authenticator, handler, authentication): c.GenericOAuthenticator.token_url = 'https://' + os.environ['NEXTCLOUD_HOST'] + '/index.php/apps/oauth2/api/v1/token'
user = authentication['auth_state']['oauth_user']['ocs']['data']['id'] c.GenericOAuthenticator.oauth_callback_url = 'https://' + os.environ['JUPYTER_HOST'] + '/hub/oauth_callback'
auth_state = authentication['auth_state']
authenticator.user_dict[user] = auth_state
return authentication
class NextcloudOAuthenticator(GenericOAuthenticator):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.user_dict = {}
def pre_spawn_start(self, user, spawner):
super().pre_spawn_start(user, spawner)
access_token = self.user_dict[user.name]['access_token']
# refresh_token = self.user_dict[user.name]['refresh_token']
spawner.environment['NEXTCLOUD_ACCESS_TOKEN'] = access_token
c.JupyterHub.authenticator_class = NextcloudOAuthenticator
c.NextcloudOAuthenticator.client_id = os.environ['NEXTCLOUD_CLIENT_ID']
c.NextcloudOAuthenticator.client_secret = os.environ['NEXTCLOUD_CLIENT_SECRET']
c.NextcloudOAuthenticator.login_service = 'Sunet Drive'
c.NextcloudOAuthenticator.username_key = lambda r: r.get('ocs', {}).get(
'data', {}).get('id')
c.NextcloudOAuthenticator.userdata_url = 'https://' + os.environ[
'NEXTCLOUD_HOST'] + '/ocs/v2.php/cloud/user?format=json'
c.NextcloudOAuthenticator.authorize_url = 'https://' + os.environ[
'NEXTCLOUD_HOST'] + '/index.php/apps/oauth2/authorize'
c.NextcloudOAuthenticator.token_url = 'https://' + os.environ[
'NEXTCLOUD_HOST'] + '/index.php/apps/oauth2/api/v1/token'
c.NextcloudOAuthenticator.oauth_callback_url = 'https://' + os.environ[
'JUPYTER_HOST'] + '/hub/oauth_callback'
c.NextcloudOAuthenticator.refresh_pre_spawn = True
c.NextcloudOAuthenticator.enable_auth_state = True
c.NextcloudOAuthenticator.post_auth_hook = post_auth_hook
# memoize so we only load config once
@lru_cache()
def _load_config():
"""Load configuration from disk
Memoized to only load once
"""
cfg = {}
for source in ('config', 'secret'):
path = f"/etc/jupyterhub/{source}/values.yaml"
if os.path.exists(path):
print(f"Loading {path}")
with open(path) as f:
values = yaml.safe_load(f)
cfg = _merge_dictionaries(cfg, values)
else:
print(f"No config at {path}")
return cfg
def _merge_dictionaries(a, b):
"""Merge two dictionaries recursively.
Simplified From https://stackoverflow.com/a/7205107
"""
merged = a.copy()
for key in b:
if key in a:
if isinstance(a[key], Mapping) and isinstance(b[key], Mapping):
merged[key] = _merge_dictionaries(a[key], b[key])
else:
merged[key] = b[key]
else:
merged[key] = b[key]
return merged
def get_config(key, default=None):
"""
Find a config item of a given name & return it
Parses everything as YAML, so lists and dicts are available too
get_config("a.b.c") returns config['a']['b']['c']
"""
value = _load_config()
# resolve path in yaml
for level in key.split('.'):
if not isinstance(value, dict):
# a parent is a scalar or null,
# can't resolve full path
return default
if level not in value:
return default
else:
value = value[level]
return value
async def fetch_new_token(token_url, client_id, client_secret, refresh_token):
params = {
"grant_type": "refresh_token",
"client_id": client_id,
"client_secret": client_secret,
"refresh_token": refresh_token,
}
body = urlencode(params)
req = HTTPRequest(token_url, 'POST', body=body)
app_log.error("URL: %s body: %s", token_url, body)
client = AsyncHTTPClient()
resp = await client.fetch(req)
resp_json = json.loads(resp.body.decode('utf8', 'replace'))
return resp_json
class TokenHandler(HubAuthenticated, RequestHandler):
def api_request(self, method, url, **kwargs):
"""Make an API request"""
url = url_path_join(self.hub_auth.api_url, url)
allow_404 = kwargs.pop('allow_404', False)
headers = kwargs.setdefault('headers', {})
headers.setdefault('Authorization',
'token %s' % self.hub_auth.api_token)
try:
r = requests.request(method, url, **kwargs)
except requests.ConnectionError as e:
app_log.error("Error connecting to %s: %s", url, e)
msg = "Failed to connect to Hub API at %r." % url
msg += " Is the Hub accessible at this URL (from host: %s)?" % socket.gethostname(
)
if '127.0.0.1' in url:
msg += " Make sure to set c.JupyterHub.hub_ip to an IP accessible to" + \
" single-user servers if the servers are not on the same host as the Hub."
raise HTTPError(500, msg)
data = None
if r.status_code == 404 and allow_404:
pass
elif r.status_code == 403:
app_log.error(
"I don't have permission to check authorization with JupyterHub, my auth token may have expired: [%i] %s",
r.status_code, r.reason)
app_log.error(r.text)
raise HTTPError(
500,
"Permission failure checking authorization, I may need a new token"
)
elif r.status_code >= 500:
app_log.error("Upstream failure verifying auth token: [%i] %s",
r.status_code, r.reason)
app_log.error(r.text)
raise HTTPError(
502, "Failed to check authorization (upstream problem)")
elif r.status_code >= 400:
app_log.warning("Failed to check authorization: [%i] %s",
r.status_code, r.reason)
app_log.warning(r.text)
raise HTTPError(500, "Failed to check authorization")
else:
data = r.json()
return data
@authenticated
async def get(self):
oauth_config = get_config('auth.custom.config')
client_id = oauth_config['client_id']
client_secret = oauth_config['client_secret']
token_url = oauth_config['token_url']
user_model = self.get_current_user()
# Fetch current auth state
u = self.api_request('GET', url_path_join('users', user_model['name']))
app_log.error("User: %s", u)
auth_state = u['auth_state']
new_tokens = await fetch_new_token(token_url, client_id, client_secret,
auth_state.get('refresh_token'))
# update auth state in the hub
auth_state['access_token'] = new_tokens['access_token']
auth_state['refresh_token'] = new_tokens['refresh_token']
self.api_request('PATCH',
url_path_join('users', user_model['name']),
data=json.dumps({'auth_state': auth_state}))
# send new token to the user
tokens = {'access_token': auth_state.get('access_token')}
self.set_header('content-type', 'application/json')
self.write(json.dumps(tokens, indent=1, sort_keys=True))
class PingHandler(RequestHandler):
def get(self):
self.set_header('content-type', 'application/json')
self.write(json.dumps({'ping': 1}))
def main():
tornado.options.parse_command_line()
app = Application([
(os.environ['JUPYTERHUB_SERVICE_PREFIX'] + 'tokens', TokenHandler),
(os.environ['JUPYTERHUB_SERVICE_PREFIX'] + '/?', PingHandler),
])
http_server = HTTPServer(app)
url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
http_server.listen(url.port)
IOLoop.current().start()
if __name__ == '__main__':
main()
extraEnv: extraEnv:
NEXTCLOUD_HOST: sunet.drive.test.sunet.se NEXTCLOUD_HOST: sunet.drive.test.sunet.se
JUPYTER_HOST: jupyter.drive.test.sunet.se JUPYTER_HOST: jupyter.drive.test.sunet.se
@ -259,6 +34,7 @@ hub:
secretKeyRef: secretKeyRef:
name: nextcloud-oauth-secrets name: nextcloud-oauth-secrets
key: client-secret key: client-secret
singleuser: singleuser:
image: image:
name: docker.sunet.se/drive/jupyter-custom name: docker.sunet.se/drive/jupyter-custom