Skip to content
Permalink
Browse files Browse the repository at this point in the history
Merge pull request from GHSA-4w59-c3gc-rrhp
Introduce maximum length of refresh tokens
  • Loading branch information
frankcorneliusmartin committed Feb 28, 2023
2 parents 32ec34a + 1095521 commit 48ebfca
Show file tree
Hide file tree
Showing 8 changed files with 160 additions and 36 deletions.
12 changes: 9 additions & 3 deletions docs/server/yaml/server_config.yaml
@@ -1,6 +1,6 @@
application: {}
# you may also add your configuration here and leave environments empty
...

environments:
# name of the environment (should be 'test', 'prod', 'acc' or 'dev')
test:
Expand Down Expand Up @@ -84,6 +84,11 @@ environments:
# set how long reset token provided via email are valid (default 1 hour)
email_token_validity_minutes: 60

# set how long tokens and refresh tokens are valid (default 6 and 48
# hours, respectively)
token_expires_hours: 6
refresh_token_expires_hours: 48

# If algorithm containers need direct communication between each other
# the server also requires a VPN server. (!) This must be a EduVPN
# instance as vantage6 makes use of their API (!)
Expand All @@ -102,5 +107,6 @@ environments:
portal_username: your_eduvpn_portal_user_name
portal_userpass: your_eduvpn_portal_user_password

prod:
...
prod: {}
acc: {}
dev: {}
1 change: 1 addition & 0 deletions vantage6-client/vantage6/client/__init__.py
Expand Up @@ -366,6 +366,7 @@ def refresh_token(self) -> None:
raise Exception("Authentication Error!")

self._access_token = response.json()["access_token"]
self.__refresh_token = response.json()["refresh_token"]

# TODO BvB 23-01-23 remove this method in v4+. It is only here for
# backwards compatibility
Expand Down
3 changes: 3 additions & 0 deletions vantage6-node/vantage6/node/__init__.py
Expand Up @@ -486,6 +486,9 @@ def authenticate(self) -> None:
self.log.critical('Unable to authenticate. Exiting')
exit(1)

# start thread to keep the connection alive by refreshing the token
self.server_io.auto_refresh_token()

def private_key_filename(self) -> Path:
"""Get the path to the private key."""

Expand Down
3 changes: 3 additions & 0 deletions vantage6-node/vantage6/node/globals.py
Expand Up @@ -44,3 +44,6 @@
# SSH TUNNEL RELATED CONSTANTS
#
SSH_TUNNEL_IMAGE = "harbor2.vantage6.ai/infrastructure/ssh-tunnel"

# start trying to refresh the JWT token 10 minutes before it expires.
REFRESH_BEFORE_EXPIRES_SECONDS = 600
24 changes: 24 additions & 0 deletions vantage6-node/vantage6/node/server_io.py
Expand Up @@ -4,10 +4,14 @@
"""
import jwt
import datetime
import time

from typing import Dict, Tuple
from threading import Thread

from vantage6.common import WhoAmI
from vantage6.client import ClientBase
from vantage6.node.globals import REFRESH_BEFORE_EXPIRES_SECONDS


class NodeClient(ClientBase):
Expand Down Expand Up @@ -61,6 +65,26 @@ def authenticate(self, api_key: str):
organization_name=organization_name
)

def auto_refresh_token(self) -> None:
""" Start a thread that refreshes token before it expires. """
# set up thread to refresh token
t = Thread(target=self.__refresh_token_worker, daemon=True)
t.start()

def __refresh_token_worker(self) -> None:
""" Keep refreshing token to prevent it from expiring. """
while True:
# get the time until the token expires
expiry_time = jwt.decode(
self.token, options={"verify_signature": False})["exp"]
time_until_expiry = expiry_time - time.time()
if time_until_expiry < REFRESH_BEFORE_EXPIRES_SECONDS:
self.refresh_token()
else:
time.sleep(
int(time_until_expiry - REFRESH_BEFORE_EXPIRES_SECONDS + 1)
)

def request_token_for_container(self, task_id: int, image: str):
""" Request a container-token at the central server.
Expand Down
86 changes: 78 additions & 8 deletions vantage6-server/vantage6/server/__init__.py
Expand Up @@ -36,13 +36,15 @@
from vantage6.server.permission import RuleNeed, PermissionManager
from vantage6.server.globals import (
APPNAME,
JWT_ACCESS_TOKEN_EXPIRES,
ACCESS_TOKEN_EXPIRES_HOURS,
JWT_TEST_ACCESS_TOKEN_EXPIRES,
RESOURCES,
SUPER_USER_INFO,
REFRESH_TOKENS_EXPIRE,
REFRESH_TOKENS_EXPIRE_HOURS,
DEFAULT_SUPPORT_EMAIL_ADDRESS,
MAX_RESPONSE_TIME_PING
MAX_RESPONSE_TIME_PING,
MIN_TOKEN_VALIDITY_SECONDS,
MIN_REFRESH_TOKEN_EXPIRY_DELTA,
)
from vantage6.server.resource.common.swagger_templates import swagger_template
from vantage6.server._version import __version__
Expand Down Expand Up @@ -145,7 +147,7 @@ def setup_socket_connection(self):

@staticmethod
def configure_logging():
"""Turn 3rd party loggers off."""
"""Set third party loggers to a warning level"""

# Prevent logging from urllib3
logging.getLogger("urllib3").setLevel(logging.WARNING)
Expand All @@ -165,9 +167,6 @@ def configure_flask(self):
# patch where to obtain token
self.app.config['JWT_AUTH_URL_RULE'] = '/api/token'

# False means refresh tokens never expire
self.app.config['JWT_REFRESH_TOKEN_EXPIRES'] = REFRESH_TOKENS_EXPIRE

# If no secret is set in the config file, one is generated. This
# implies that all (even refresh) tokens will be invalidated on restart
self.app.config['JWT_SECRET_KEY'] = self.ctx.config.get(
Expand All @@ -176,7 +175,20 @@ def configure_flask(self):
)

# Default expiration time
self.app.config['JWT_ACCESS_TOKEN_EXPIRES'] = JWT_ACCESS_TOKEN_EXPIRES
token_expiry_seconds = self._get_jwt_expiration_seconds(
config_key='token_expires_hours',
default_hours=ACCESS_TOKEN_EXPIRES_HOURS
)
self.app.config['JWT_ACCESS_TOKEN_EXPIRES'] = token_expiry_seconds

# Set refresh token expiration time
self.app.config['JWT_REFRESH_TOKEN_EXPIRES'] = \
self._get_jwt_expiration_seconds(
config_key='refresh_token_expires_hours',
default_hours=REFRESH_TOKENS_EXPIRE_HOURS,
longer_than=token_expiry_seconds + MIN_REFRESH_TOKEN_EXPIRY_DELTA,
is_refresh=True
)

# Set an extra long expiration time on access tokens for testing
# TODO: this does not seem needed...
Expand Down Expand Up @@ -284,6 +296,64 @@ def static_from_root():
return send_from_directory(self.app.static_folder,
request.path[1:])


def _get_jwt_expiration_seconds(
self, config_key: str, default_hours: int,
longer_than: int = MIN_TOKEN_VALIDITY_SECONDS,
is_refresh: bool = False
) -> int:
"""
Return the expiration time for JWT tokens.
This time may be specified in the config file. If it is not, the
default value is returned.
Parameters
----------
config_key: str
The config key to look for that sets the expiration time
default_hours: int
The default expiration time in hours
longer_than: int
The minimum expiration time in hours.
is_refresh: bool
If True, the expiration time is for a refresh token. If False, it
is for an access token.
Returns
-------
int:
The JWT token expiration time in seconds
"""
hours_expire = self.ctx.config.get(config_key)
if hours_expire is None:
# No value is present in the config file, use default
refresh_expire = int(float(default_hours) * 3600)
elif isinstance(hours_expire, (int, float)) or \
hours_expire.is_numeric():
# Numeric value is present in the config file
refresh_expire = int(float(hours_expire) * 3600)
if refresh_expire < longer_than:
log.warning(
f"Invalid value for '{config_key}': {hours_expire}. Tokens"
f" must be valid for at least {longer_than} seconds. Using"
f" default value: {REFRESH_TOKENS_EXPIRE_HOURS} hours")
if is_refresh:
log.warning("Note that refresh tokens should be valid at "
f"least {MIN_REFRESH_TOKEN_EXPIRY_DELTA} "
"seconds longer than access tokens.")
refresh_expire = int(float(REFRESH_TOKENS_EXPIRE_HOURS) * 3600)
else:
# Non-numeric value is present in the config file. Warn and use
# default
log.warning("Invalid value for 'refresh_token_expires_hours':"
f" {hours_expire}. Using default value: "
f"{REFRESH_TOKENS_EXPIRE_HOURS} hours")
refresh_expire = int(float(REFRESH_TOKENS_EXPIRE_HOURS) * 3600)

return refresh_expire


def configure_api(self):
""""Define global API output."""

Expand Down
15 changes: 11 additions & 4 deletions vantage6-server/vantage6/server/globals.py
Expand Up @@ -16,7 +16,10 @@
#

# Expiretime of JWT tokens
JWT_ACCESS_TOKEN_EXPIRES = datetime.timedelta(hours=6)
ACCESS_TOKEN_EXPIRES_HOURS = datetime.timedelta(hours=6)

# minimum validity of JWT Tokens in seconds
MIN_TOKEN_VALIDITY_SECONDS = 1800

# Expiretime of JWT token in a test environment
JWT_TEST_ACCESS_TOKEN_EXPIRES = datetime.timedelta(days=1)
Expand All @@ -34,9 +37,13 @@
"password": "root"
}

# Whenever the refresh tokens should expire. Note that setting this to true
# would mean that nodes will disconnect after some time
REFRESH_TOKENS_EXPIRE = False
# Expiration time of refresh tokens
REFRESH_TOKENS_EXPIRE_HOURS = 48

# Minimum time in seconds that a refresh token must be valid *longer than* the
# access token. This is to prevent the access token from expiring before the
# refresh token.
MIN_REFRESH_TOKEN_EXPIRY_DELTA = 1

# default support email address
DEFAULT_SUPPORT_EMAIL_ADDRESS = 'support@vantage6.ai'
Expand Down
52 changes: 31 additions & 21 deletions vantage6-server/vantage6/server/resource/token.py
Expand Up @@ -14,6 +14,7 @@
create_refresh_token,
get_jwt_identity
)
from flask_restful import Api
from http import HTTPStatus

from vantage6 import server
Expand Down Expand Up @@ -152,18 +153,10 @@ def post(self):
"incorrect!"
}, HTTPStatus.UNAUTHORIZED

token = create_access_token(user)

ret = {
'access_token': token,
'refresh_token': create_refresh_token(user),
'user_url': self.api.url_for(server.resource.user.User,
id=user.id),
'refresh_url': self.api.url_for(RefreshToken),
}
token = _get_token_dict(user, self.api)

log.info(f"Succesfull login from {username}")
return ret, HTTPStatus.OK, {'jwt-token': token}
return token, HTTPStatus.OK, {'jwt-token': token['access_token']}

def user_login(self, username: str, password: str) -> Union[dict, db.User]:
"""Returns user a message in case of failed login attempt."""
Expand Down Expand Up @@ -292,17 +285,10 @@ def post(self):
return {"msg": "Api key is not recognized!"}, \
HTTPStatus.UNAUTHORIZED

token = create_access_token(node)
ret = {
'access_token': token,
'refresh_token': create_refresh_token(node),
'node_url': self.api.url_for(server.resource.node.Node,
id=node.id),
'refresh_url': self.api.url_for(RefreshToken),
}
token = _get_token_dict(node, self.api)

log.info(f"Succesfull login as node '{node.id}' ({node.name})")
return ret, HTTPStatus.OK, {'jwt-token': token}
return token, HTTPStatus.OK, {'jwt-token': token['access_token']}


class ContainerToken(ServicesResources):
Expand Down Expand Up @@ -412,6 +398,30 @@ def post(self):
user_or_node_id = get_jwt_identity()
log.info(f'Refreshing token for user or node "{user_or_node_id}"')
user_or_node = db.Authenticatable.get(user_or_node_id)
ret = {'access_token': create_access_token(user_or_node)}

return ret, HTTPStatus.OK
return _get_token_dict(user_or_node, self.api), HTTPStatus.OK


def _get_token_dict(user_or_node: db.Authenticatable, api: Api) -> dict:
"""
Create a dictionary with the tokens and urls for the user or node.
Parameters
----------
user_or_node : db.Authenticatable
The user or node to create the tokens for.
api : Api
The api to create the urls for.
"""
token_dict = {
'access_token': create_access_token(user_or_node),
'refresh_token': create_refresh_token(user_or_node),
'refresh_url': api.url_for(RefreshToken),
}
if isinstance(user_or_node, db.User):
token_dict['user_url'] = api.url_for(server.resource.user.User,
id=user_or_node.id)
else:
token_dict['node_url'] = api.url_for(server.resource.node.Node,
id=user_or_node.id)
return token_dict

0 comments on commit 48ebfca

Please sign in to comment.