diff --git a/admin/polytope-admin/polytope_admin/api/Auth.py b/admin/polytope-admin/polytope_admin/api/Auth.py index 7a6cd40..b329189 100644 --- a/admin/polytope-admin/polytope_admin/api/Auth.py +++ b/admin/polytope-admin/polytope_admin/api/Auth.py @@ -150,12 +150,12 @@ def fetch_key(self, login=True): email = self.read_email self._logger.info("Polytope user key found in session cache for user " + config["username"]) else: - key_file = Path(config["key_path"]) / config["username"] + key_file = Path(config["key_path"]) try: with open(str(key_file), "r") as infile: info = json.load(infile) - key = info["key"] - email = info["email"] + key = info["user_key"] + email = info["user_email"] except FileNotFoundError: key = None email = None @@ -190,7 +190,7 @@ def persist(self, key, email, username=None): if not username: username = config["username"] os.makedirs(config["key_path"], exist_ok=True) - key_file = Path(config["key_path"]) / username + key_file = Path(config["key_path"]) with open(str(key_file), "w", encoding="utf8") as outfile: json.dump({"key": key, "email": email}, outfile) self.read_key = key @@ -214,7 +214,7 @@ def erase(self, username=None): config = self.config.get() if not username: username = config["username"] - key_path = Path(config["key_path"]) / username + key_path = Path(config["key_path"]) try: os.remove(str(key_path)) self._logger.info("Credentials removed for " + username) diff --git a/polytope_server/common/authentication/keycloak_bearer_authentication.py b/polytope_server/common/authentication/keycloak_bearer_authentication.py new file mode 100644 index 0000000..8f26284 --- /dev/null +++ b/polytope_server/common/authentication/keycloak_bearer_authentication.py @@ -0,0 +1,123 @@ +# +# Copyright 2022 European Centre for Medium-Range Weather Forecasts (ECMWF) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# In applying this licence, ECMWF does not waive the privileges and immunities +# granted to it by virtue of its status as an intergovernmental organisation nor +# does it submit to any jurisdiction. +# + +import base64 +import logging +import os + +from keycloak import KeycloakOpenID +from keycloak.exceptions import KeycloakConnectionError + +from ..auth import User +from ..caching import cache +from ..exceptions import ForbiddenRequest +from . import authentication + + +class KeycloakBearerAuthentication(authentication.Authentication): + def __init__(self, name, realm, config): + self.config = config + + # URL of the keycloak API: e.g. https://keycloak.insitute.org/auth/" + self.url = config["url"] + + # Keycloak client id and secret + self.client_id = config["client_id"] # e.g. polytope + self.client_secret = config["client_secret"] + + # The keycloak realm to look for users + self.keycloak_realm = config["keycloak_realm"] + + self.skipTLS = config.get("skip_tls", False) + + # Connection parameters + self.timeout = config.get("timeout", 3) + + # Mapping user attributes to keycloak attributes + self.attribute_map = config.get("attributes", {}) + + super().__init__(name, realm, config) + + def authentication_type(self): + return "Basic" + + def authentication_info(self): + return "Authenticate with Keycloak username and password" + + @cache(lifetime=120) + def authenticate(self, credentials: str) -> User: + + # credentials should be of the form 'base64(:)' + try: + decoded = base64.b64decode(credentials).decode("utf-8") + auth_user, auth_password = decoded.split(":", 1) + except UnicodeDecodeError: + raise ForbiddenRequest("Credentials could not be decoded") + except ValueError: + raise ForbiddenRequest("Credentials could not be unpacked") + + _environ = dict(os.environ) + try: + os.environ["http_proxy"] = os.getenv("POLYTOPE_PROXY", "") + os.environ["https_proxy"] = os.getenv("POLYTOPE_PROXY", "") + + logging.debug("Setting HTTPS_PROXY to {}".format(os.environ["https_proxy"])) + + try: + + # Open a session as a registered client + client = KeycloakOpenID( + server_url=self.url, + client_id=self.client_id, + realm_name=self.keycloak_realm, + client_secret_key=self.client_secret, + verify=(self.skipTLS is False), + ) + + client.connection.timeout = self.timeout + + # Obtain a session token on behalf of the user + token = client.token(auth_user, auth_password) + + except KeycloakConnectionError: + # Raise ForbiddenRequest rather than ServerError so that we are not blocked if Keycloak is down + raise ForbiddenRequest("Could not connect to Keycloak") + except Exception: + raise ForbiddenRequest("Invalid Keycloak credentials") + + userinfo = client.userinfo(token["access_token"]) + + user = User(auth_user, self.realm()) + + logging.debug("Found user {} in keycloak".format(auth_user)) + + for k, v in self.attribute_map.items(): + if v in userinfo: + user.attributes[k] = userinfo[v] + logging.debug("User {} has attribute {} : {}".format(user.username, k, user.attributes[k])) + + return user + + finally: + os.environ.clear() + os.environ.update(_environ) + + def collect_metric_info(self): + return {}