Skip to content

Commit

Permalink
Merge pull request #125 from IN-CORE/release-1.8.0
Browse files Browse the repository at this point in the history
Release 1.8.0
  • Loading branch information
longshuicy authored Aug 16, 2024
2 parents 2581f1d + 4d85ad2 commit ea897d2
Show file tree
Hide file tree
Showing 3 changed files with 95 additions and 66 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [1.8.0] - 2024-08-21
### Changed
- Updated base image forJupyterLab to 4.1 [#122](https://github.com/IN-CORE/incore-lab/issues/122)

### Changed
- UID logic. We will no longer depend upon the uid parameter from LDAP in JWT token. [#119](https://github.com/IN-CORE/incore-lab/issues/119)

## [1.7.0] - 2024-06-12
### Changed
- IN-CORE relase for 5.4.0 with pyincore 1.19.0 [#116](https://github.com/IN-CORE/incore-lab/issues/116)
Expand Down
6 changes: 3 additions & 3 deletions Dockerfile.lab
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM jupyterhub/singleuser:4.0.2
FROM jupyterhub/singleuser:4.1

USER root
RUN apt-get -qq update && apt-get install -y --no-install-recommends \
Expand Down Expand Up @@ -33,8 +33,8 @@ ENV INCORE=$INCORE \
RUN conda install -c conda-forge mamba

# Set mamba channels
RUN mamba config --add channels conda-forge && \
mamba config --add channels in-core
RUN conda config --add channels conda-forge && \
conda config --add channels in-core

# Install pyincore, pyincore-viz and other mamba packages
RUN umask 0 && \
Expand Down
148 changes: 85 additions & 63 deletions authenticator/customauthenticator/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,93 +10,93 @@
from tornado import web
import requests


class CustomTokenAuthenticator(Authenticator):
"""
Accept the authenticated Access Token from cookie.
Accept the authenticated Access Token from cookie.
"""

auth_cookie_header = Unicode(
os.environ.get('AUTH_COOKIE_HEADER', ''),
os.environ.get("AUTH_COOKIE_HEADER", ""),
config=True,
help="the cookie header we put in browser to retrieve token",
)

auth_username_key = Unicode(
os.environ.get('AUTH_USERNAME_KEY', ''),
os.environ.get("AUTH_USERNAME_KEY", ""),
config=True,
help="the key to retreive username from the json",
)

auth_uid_number_key = Unicode(
os.environ.get('AUTH_UID_NUMBER_KEY', ''),
config=True,
help="the key to retreive uid from the json",
)

landing_page_login_url = Unicode(
os.environ.get('LANDING_PAGE_LOGIN_URL', ''),
os.environ.get("LANDING_PAGE_LOGIN_URL", ""),
config=True,
help="the landing page login entry",
)

keycloak_url = Unicode(
os.environ.get('KEYCLOAK_URL', ''),
os.environ.get("KEYCLOAK_URL", ""),
config=True,
help="the URL where keycloak is installed",
)

keycloak_audience = Unicode(
os.environ.get('KEYCLOAK_AUDIENCE', ''),
os.environ.get("KEYCLOAK_AUDIENCE", ""),
config=True,
help="the audience for keycloak to check",
)

keycloak_pem_key = Unicode(
os.environ.get('KEYCLOAK_PEM_KEY', ''),
os.environ.get("KEYCLOAK_PEM_KEY", ""),
config=True,
help="the RSA pem key with proper header and footer (deprecated)",
)

auth_group = Unicode(
os.environ.get('AUTH_GROUP', ''),
os.environ.get("AUTH_GROUP", ""),
config=True,
help="the user group for incore jupyterhub authenticator"
help="the user group for incore jupyterhub authenticator",
)

auth_role = Unicode(
os.environ.get('AUTH_ROLE', ''),
os.environ.get("AUTH_ROLE", ""),
config=True,
help="the user role for incore jupyterhub authenticator"
help="the user role for incore jupyterhub authenticator",
)

space_service_url = Unicode(
os.environ.get('SPACE_SERVICE_URL', ''),
os.environ.get("SPACE_SERVICE_URL", ""),
config=True,
help="the internal space service url"
help="the internal space service url",
)

quotas = None

def get_handlers(self, app):
return [
(r'/', LoginHandler),
(r'/user', LoginHandler),
(r'/lab', LoginHandler),
(r'/login', LoginHandler),
(r'/logout', CustomTokenLogoutHandler),
(r"/", LoginHandler),
(r"/user", LoginHandler),
(r"/lab", LoginHandler),
(r"/login", LoginHandler),
(r"/logout", CustomTokenLogoutHandler),
]

def get_keycloak_pem(self):
if not self.keycloak_url:
raise web.HTTPError(500, log_message="JupyterHub is not correctly configured.")
raise web.HTTPError(
500, log_message="JupyterHub is not correctly configured."
)

# fetch the key
response = urllib.request.urlopen(self.keycloak_url)
if response.code >= 200 or response <= 299:
encoding = response.info().get_content_charset('utf-8')
encoding = response.info().get_content_charset("utf-8")
result = json.loads(response.read().decode(encoding))
self.keycloak_pem_key = f"-----BEGIN PUBLIC KEY-----\n" \
f"{result['public_key']}\n" \
f"-----END PUBLIC KEY-----"
self.keycloak_pem_key = (
f"-----BEGIN PUBLIC KEY-----\n"
f"{result['public_key']}\n"
f"-----END PUBLIC KEY-----"
)
else:
raise web.HTTPError(500, log_message="Could not get key from keycloak.")

Expand All @@ -107,15 +107,19 @@ def check_jwt_token(self, access_token):

# make sure audience is set
if not self.keycloak_audience:
raise web.HTTPError(403, log_message="JupyterHub is not correctly configured.")
raise web.HTTPError(
403, log_message="JupyterHub is not correctly configured."
)

# no token in the cookie
if not access_token:
raise web.HTTPError(401, log_message="Please login to access IN-CORE Lab.")

# make sure it is a valid token
if len(access_token.split(" ")) != 2 or access_token.split(" ")[0] != 'bearer':
raise web.HTTPError(403, log_message="Token format not valid, it has to be bearer xxxx!")
if len(access_token.split(" ")) != 2 or access_token.split(" ")[0] != "bearer":
raise web.HTTPError(
403, log_message="Token format not valid, it has to be bearer xxxx!"
)

# decode jwt token instead of sending it to userinfo endpoint:
access_token = access_token.split(" ")[1]
Expand All @@ -124,24 +128,29 @@ def check_jwt_token(self, access_token):
try:
resp_json = jwt.decode(access_token, public_key, audience=audience)
except ExpiredSignatureError:
raise web.HTTPError(403, log_message='JWT Expired Signature Error: token signature has expired')
raise web.HTTPError(
403,
log_message="JWT Expired Signature Error: token signature has expired",
)
except JWTClaimsError:
raise web.HTTPError(403, log_message='JWT Claims Error: token signature is invalid')
raise web.HTTPError(
403, log_message="JWT Claims Error: token signature is invalid"
)
except JWTError:
raise web.HTTPError(403, log_message='JWT Error: token signature is invalid')
raise web.HTTPError(
403, log_message="JWT Error: token signature is invalid"
)
except Exception as e:
raise web.HTTPError(403, log_message="Not a valid jwt token!")

# make sure we know username
if self.auth_username_key not in resp_json.keys():
raise web.HTTPError(500, log_message=f"Required field {self.auth_username_key} does not exist in jwt token")
raise web.HTTPError(
500,
log_message=f"Required field {self.auth_username_key} does not exist in jwt token",
)
username = resp_json[self.auth_username_key]

# make sure there is a user id
if self.auth_uid_number_key not in resp_json.keys():
raise web.HTTPError(500, log_message=f"Required field {self.auth_uid_number_key} does not exist in jwt token")
uid = resp_json[self.auth_uid_number_key]

# get the groups/roles for the user
user_groups = resp_json.get("groups", [])
if "roles" in resp_json:
Expand All @@ -153,39 +162,48 @@ def check_jwt_token(self, access_token):

# check authorization
if self.auth_group not in user_groups and self.auth_role not in user_roles:
raise web.HTTPError(403, log_message="The current user does not belongs to incore user group and " +
"cannot access incore lab. Please contact NCSA IN-CORE development team")
raise web.HTTPError(
403,
log_message="The current user does not belongs to incore user group and "
+ "cannot access incore lab. Please contact NCSA IN-CORE development team",
)

admin = False
if "incore_admin" in user_groups or "incore_admin" in user_roles:
admin = True

self.log.info(f"username={username} logged in with uid={uid}")
self.log.info(f"username={username} logged in")
return {
'name': username,
'admin': admin,
'auth_state': {
'uid': uid,
'groups': user_groups,
'roles': user_roles,
"name": username,
"admin": admin,
"auth_state": {
"groups": user_groups,
"roles": user_roles,
},
}

async def authenticate(self, handler, data):
self.log.info("Authenticate")
try:
access_token = urllib.parse.unquote(handler.get_cookie(self.auth_cookie_header, ""))
access_token = urllib.parse.unquote(
handler.get_cookie(self.auth_cookie_header, "")
)
if not access_token:
raise web.HTTPError(401, log_message="Please login to access IN-CORE Lab.")
raise web.HTTPError(
401, log_message="Please login to access IN-CORE Lab."
)

# check token and authorization
user = self.check_jwt_token(access_token)
return user
except web.HTTPError as e:
if e.log_message:
error_msg = urllib.parse.quote(e.log_message.encode('utf-8'))
error_msg = urllib.parse.quote(e.log_message.encode("utf-8"))
else:
error_msg = urllib.parse.quote(f"Error {e}".encode('utf-8')) + ". Please login to access IN-CORE Lab."
error_msg = (
urllib.parse.quote(f"Error {e}".encode("utf-8"))
+ ". Please login to access IN-CORE Lab."
)
handler.redirect(f"{self.landing_page_login_url}?error={error_msg}")

def find_quota(self, user, auth_state):
Expand All @@ -197,31 +215,33 @@ def find_quota(self, user, auth_state):

# Define the headers
headers = {
"x-auth-userinfo": json.dumps({"preferred_username":user.name}),
"x-auth-usergroup": json.dumps({"groups": []})
"x-auth-userinfo": json.dumps({"preferred_username": user.name}),
"x-auth-usergroup": json.dumps({"groups": []}),
}

resp = requests.get(url, headers=headers)

if resp.status_code == 200 and "incoreLab" in resp.json():
self.log.info(f"Quota for current user:{user.name}")
self.log.info(json.dumps(resp.json()["incoreLab"]))
return resp.json()["incoreLab"]
else:
self.log.exception(f"Request failed with status code: {resp.status_code}")
self.log.exception(
f"Request failed with status code: {resp.status_code}"
)

except:
self.log.exception("Could not load quota")
# default quotas
return { "cpu": [ 1, 2 ], "mem": [ 2, 4 ], "disk": 4, "service": [100, 2]}
return {"cpu": [1, 2], "mem": [2, 4], "disk": 4, "service": [100, 2]}

async def pre_spawn_start(self, user, spawner):
auth_state = await user.get_auth_state()
if not auth_state:
self.log.error("No auth state")
return

spawner.environment['NB_USER'] = user.name
spawner.environment['NB_UID'] = str(auth_state['uid'])
spawner.environment["NB_USER"] = user.name

quota = self.find_quota(user, auth_state)
if "cpu" in quota:
Expand All @@ -237,6 +257,7 @@ async def pre_spawn_start(self, user, spawner):
spawner.mem_guarantee = "2G"
spawner.mem_limit = "4G"


#
# # This is called from the jupyterlab so there is no cookies that this depends on
# async def refresh_user(self, user, handler):
Expand All @@ -261,8 +282,9 @@ class CustomTokenLogoutHandler(LogoutHandler):
async def handle_logout(self):
# remove incore token on logout
self.log.info("Remove incore token on logout")
error_msg = "You have logged out of IN-CORE system from IN-CORE lab. Please login again if you want to use " \
"IN-CORE components."
error_msg = (
"You have logged out of IN-CORE system from IN-CORE lab. Please login again if you want to use "
"IN-CORE components."
)
self.set_cookie(self.authenticator.auth_cookie_header, "")
self.redirect(f"{self.authenticator.landing_page_login_url}?error={error_msg}")

0 comments on commit ea897d2

Please sign in to comment.