diff --git a/MANIFEST.in b/MANIFEST.in --- a/MANIFEST.in +++ b/MANIFEST.in @@ -6,6 +6,7 @@ include requirements-test.txt include tox.ini include version.txt +include swh/web/settings/keycloak/swh_web_api_authorization_config.json recursive-include swh/web/assets * recursive-include swh/web/static * recursive-include swh/web/templates * diff --git a/requirements.txt b/requirements.txt --- a/requirements.txt +++ b/requirements.txt @@ -18,9 +18,8 @@ pyyaml requests python-memcached +python-keycloak # Doc dependencies sphinx sphinxcontrib-httpdomain - - diff --git a/swh/web/api/urls.py b/swh/web/api/urls.py --- a/swh/web/api/urls.py +++ b/swh/web/api/urls.py @@ -3,6 +3,7 @@ # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information +import swh.web.api.views.auth # noqa import swh.web.api.views.content # noqa import swh.web.api.views.directory # noqa import swh.web.api.views.identifiers # noqa diff --git a/swh/web/api/views/auth.py b/swh/web/api/views/auth.py new file mode 100644 --- /dev/null +++ b/swh/web/api/views/auth.py @@ -0,0 +1,101 @@ +# Copyright (C) 2019 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU Affero General Public License version 3, or any later version +# See top-level LICENSE file for more information + +import json + +from keycloak.exceptions import KeycloakError + +from rest_framework.response import Response + +from swh.web.api.apiurls import api_route +from swh.web.common.keycloak import ( + get_keycloak, KC_SWH_WEB_API_CLIENT_ID, KC_SUPPORTED_EXTERNAL_ID_PROVIDERS +) + + +@api_route(r'/auth/token/access/', 'api-1-auth-token-access', + methods=['POST']) +def auth_token_access(request): + status_code = 200 + data = {} + if 'username' not in request.data: + status_code = 400 + data['error_description'] = 'Missing username in request data' + elif 'password' not in request.data: + status_code = 400 + data['error_description'] = 'Missing password in request data' + else: + def access_token(keycloak, request): + return keycloak.token(request.data['username'], + request.data['password']) + data, status_code = _keycloak_get_token_data(request, status_code, + access_token) + return Response(data, status=status_code) + + +@api_route(r'/auth/token/exchange/', 'api-1-auth-token-exchange', + methods=['POST']) +def auth_token_exchange(request): + status_code = 200 + data = {} + if 'issuer' not in request.data: + status_code = 400 + data['error_description'] = 'Missing token issuer in request data' + elif request.data['issuer'] not in KC_SUPPORTED_EXTERNAL_ID_PROVIDERS: + status_code = 400 + data['error_description'] = ('Token issuer %s not supported' % + request.data['issuer']) + elif 'token' not in request.data: + status_code = 400 + data['error_description'] = 'Missing token to exchange in request data' + else: + def exchange_token(keycloak, request): + return keycloak.exchange_token(request.data['token'], + request.data['issuer']) + data, status_code = _keycloak_get_token_data(request, status_code, + exchange_token) + + return Response(data, status=status_code) + + +@api_route(r'/auth/token/refresh/', 'api-1-auth-token-refresh', + methods=['POST']) +def auth_token_refresh(request): + status_code = 200 + data = {} + if 'refresh_token' not in request.data: + status_code = 400 + data['error_description'] = 'Missing refresh token in request data' + else: + def refresh_token(keycloak, request): + return keycloak.refresh_token(request.data['refresh_token']) + data, status_code = _keycloak_get_token_data(request, status_code, + refresh_token) + return Response(data, status=status_code) + + +def _cleanup_token_data(token_data): + for key in ('token_type', 'not-before-policy', 'session_state', + 'scope'): + del token_data[key] + + +def _error_description(error_message): + if type(error_message) == bytes and b'error_description' in error_message: + error = json.loads(error_message) + return error['error_description'] + else: + return error_message + + +def _keycloak_get_token_data(request, status_code, get_token): + keycloak = get_keycloak(KC_SWH_WEB_API_CLIENT_ID) + try: + data = get_token(keycloak, request) + _cleanup_token_data(data) + except KeycloakError as e: + status_code = e.response_code + data = {'error_description': _error_description(e.error_message)} + return data, status_code diff --git a/swh/web/common/keycloak.py b/swh/web/common/keycloak.py new file mode 100644 --- /dev/null +++ b/swh/web/common/keycloak.py @@ -0,0 +1,229 @@ +# Copyright (C) 2019 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU Affero General Public License version 3, or any later version +# See top-level LICENSE file for more information + +import functools +import json +import os + +from keycloak import KeycloakOpenID, KeycloakAdmin +from keycloak.exceptions import ( + KeycloakInvalidTokenError +) + +from swh.web import settings +from swh.web.config import get_config + +KC_SWH_WEB_API_CLIENT_ID = 'swh-web-api' +KC_THROTTLING_EXEMPTED_PERM = 'throttling-exempted' + +KC_SUPPORTED_EXTERNAL_ID_PROVIDERS = ['github', 'gitlab'] + + +class Keycloak(object): + """ + Wrapper class to ease the interaction with Keycloak for managing + authentication and user permissions. + """ + + def _raise_if_init_failed(): + def decorator(function): + @functools.wraps(function) + def inner(self, *args, **kwargs): + if self.init_error: + raise self.init_error + else: + return function(self, *args, **kwargs) + + return inner + + return decorator + + def __init__(self, server_url, realm_config, client_config, + authorization_config_location): + """ + Args: + server_url (str): URL of the Keycloak server + + realm_config (dict): A dictionary filled with the following info + related to a Keycloak realm: + + * **name**: the realm name + * **admin_username**: the realm administrator username + * **admin_password**: the realm administrator password + * **public_key**: the realm RSA public key (optional, it + will be retrieved using Keycloak API if not provided) + + client_config (dict): A dictionary filled with the following info + related to a client from the realm: + + * **id**: the client identifier + * **default_access**: the client default access type + * **method_validate_token**: the client method to + validate tokens + * **authorization_config**: filename of JSON file + exported from Keycloak describing user permissions + * **secret_key**: the client secret key (optional, it will + be retrieved using Keycloak Admin API if not provided) + + authorization_config_location (str): path to directory containing + authorization config JSON files + + """ + self.server_url = server_url + self.realm = realm_config['name'] + self.realm_public_key = realm_config['public_key'] + self.client_id = client_config['id'] + self.client_secret_key = client_config['secret_key'] + self.default_access = client_config['default_access'] + self.method_validate_token = client_config['method_validate_token'] + self.authorization_config = client_config['authorization_config'] + self.authorization_config_location = authorization_config_location + self.init_error = None + + try: + + # create Keycloak Admin API client + self.keycloak_admin = KeycloakAdmin( + server_url=self.server_url, + username=realm_config['admin_username'], + password=realm_config['admin_password'], + realm_name=self.realm + ) + + # get client secret key if not provided + if not self.client_secret_key: + self.client_secret_key = self._get_client_secret() + + # create Keycloak OpenID API client + self.keycloak = KeycloakOpenID( + server_url=self.server_url, + client_id=self.client_id, + realm_name=self.realm, + client_secret_key=self.client_secret_key + ) + + # get realm public key if not provided + if not self.realm_public_key: + certs = self.keycloak.certs() + self.realm_public_key = json.dumps(certs['keys'][0]) + + # load authorization config if provided + if self.authorization_config: + authorization_file = os.path.join( + self.authorization_config_location, + self.authorization_config) + self.keycloak.load_authorization_config(authorization_file) + + except Exception as e: + self.init_error = e + + @_raise_if_init_failed() + def token(self, username, password): + """ + Request an access token for a Keycloak user. + + Args: + username (str): name of the user + password (str): password of the user + + Returns: + dict: A dictionary filled with token info + """ + return self.keycloak.token(username, password) + + @_raise_if_init_failed() + def refresh_token(self, refresh_token): + """ + Request an access token from Keycloak using a refresh token. + + Args: + refresh_token (str): a refresh token provided by Keycloak + + Returns: + dict: A dictionary filled with token info + """ + return self.keycloak.refresh_token(refresh_token) + + @_raise_if_init_failed() + def exchange_token(self, token, issuer): + """ + Exchange a token from a given issuer to a Keycloak one. + + Args: + token (str): an access token provided by the issuer + issuer (str): the issuer name (e.g. github, google, ...) + + Returns: + dict: A dictionary filled with token info + """ + grant_type = ['urn:ietf:params:oauth:grant-type:token-exchange'] + subject_token_type = ['urn:ietf:params:oauth:token-type:access_token'] + return self.keycloak.token(grant_type=grant_type, + subject_token=token, + subject_issuer=issuer, + subject_token_type=subject_token_type) + + @_raise_if_init_failed() + def decode_token(self, token): + """ + Try to decode a JWT token. + + Args: + token (str): a JWT token to decode + + Returns: + dict: A dictionary filled with decoded token content + """ + return self.keycloak.decode_token(token, self.realm_public_key) + + @_raise_if_init_failed() + def get_permissions(self, token): + """ + Get user permissions encoded in access token. + + Args: + token (str): an access token provided by Keycloak + + Returns: + list: A list of user permissions + """ + try: + user_permissions = self.keycloak.get_permissions( + token, method_token_info=self.method_validate_token, + key=self.realm_public_key) + except KeycloakInvalidTokenError: + user_permissions = [] + return user_permissions + + def _get_client_secret(self): + client_id_kc = self.keycloak_admin.get_client_id(self.client_id) + client_secret = self.keycloak_admin.get_client_secrets(client_id_kc) + return client_secret['value'] + + +_keycloaks = {} + + +def get_keycloak(client_id): + """ + Instantiate a Keycloak wrapper class for a given client in the + SoftwareHeritage realm. + + Args: + client_id: client identifier in the SoftwareHeritage realm + + Returns: + Keycloak: object to ease the interaction with Keycloak server + """ + if (client_id not in _keycloaks or + _keycloaks[client_id].init_error is not None): + swhweb_config = get_config() + settings_dir = os.path.dirname(settings.__file__) + _keycloaks[client_id] = Keycloak( + swhweb_config['keycloak']['server_url'], + swhweb_config['keycloak']['realm'], + swhweb_config['keycloak']['client'][client_id], + os.path.join(settings_dir, 'keycloak')) + return _keycloaks[client_id] diff --git a/swh/web/common/middlewares.py b/swh/web/common/middlewares.py --- a/swh/web/common/middlewares.py +++ b/swh/web/common/middlewares.py @@ -3,9 +3,14 @@ # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information +import json + from bs4 import BeautifulSoup +from django.http import HttpResponse from htmlmin import minify +from swh.web.common.keycloak import get_keycloak, KC_SWH_WEB_API_CLIENT_ID + class HtmlPrettifyMiddleware(object): """ @@ -68,3 +73,23 @@ if 'RateLimit-Reset' in request.META: resp['X-RateLimit-Reset'] = request.META['RateLimit-Reset'] return resp + + +class CheckAccessTokenMiddleware(object): + """ + Django middleware for checking bearer token validity. + """ + + def __init__(self, get_response=None): + self.get_response = get_response + + def __call__(self, request): + if 'HTTP_AUTHORIZATION' in request.META: + auth_header = request.META.get('HTTP_AUTHORIZATION').split() + token = auth_header[1] if len(auth_header) == 2 else auth_header[0] + try: + get_keycloak(KC_SWH_WEB_API_CLIENT_ID).decode_token(token) + except Exception as e: + data = {'error_description': str(e)} + return HttpResponse(json.dumps(data), status=401) + return self.get_response(request) diff --git a/swh/web/common/throttling.py b/swh/web/common/throttling.py --- a/swh/web/common/throttling.py +++ b/swh/web/common/throttling.py @@ -7,6 +7,9 @@ from rest_framework.throttling import ScopedRateThrottle +from swh.web.common.keycloak import ( + get_keycloak, KC_SWH_WEB_API_CLIENT_ID, KC_THROTTLING_EXEMPTED_PERM +) from swh.web.config import get_config @@ -64,6 +67,21 @@ return self.exempted_networks def allow_request(self, request, view): + + # Throttling can be exempted for Keycloak authenticated users with + # adequate permission + if 'HTTP_AUTHORIZATION' in request.META: + auth_header = request.META.get('HTTP_AUTHORIZATION').split() + token = auth_header[1] if len(auth_header) == 2 else auth_header[0] + keycloak = get_keycloak(KC_SWH_WEB_API_CLIENT_ID) + try: + permissions = keycloak.get_permissions(token) + for perm in permissions: + if perm.name == KC_THROTTLING_EXEMPTED_PERM: + return True + except Exception: + pass + # class based view case if not self.scope: default_scope = getattr(view, self.scope_attr, None) diff --git a/swh/web/config.py b/swh/web/config.py --- a/swh/web/config.py +++ b/swh/web/config.py @@ -95,6 +95,24 @@ 'e2e_tests_mode': ('bool', False), 'es_workers_index_url': ('string', ''), 'history_counters_url': ('string', 'https://stats.export.softwareheritage.org/history_counters.json'), # noqa + 'keycloak': ('dict', { + 'server_url': 'http://localhost:8080/auth/', + 'realm': { + 'name': 'SoftwareHeritage', + 'public_key': '', + 'admin_username': '', + 'admin_password': '', + }, + 'client': { + 'swh-web-api': { + 'id': 'swh-web-api', + 'default_access': 'allow', + 'method_validate_token': 'decode', + 'secret_key': '', + 'authorization_config': '', + }, + } + }), } swhweb_config = {} diff --git a/swh/web/settings/common.py b/swh/web/settings/common.py --- a/swh/web/settings/common.py +++ b/swh/web/settings/common.py @@ -47,6 +47,7 @@ ] MIDDLEWARE = [ + 'swh.web.common.middlewares.CheckAccessTokenMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', diff --git a/swh/web/settings/keycloak/swh_web_api_authorization_config.json b/swh/web/settings/keycloak/swh_web_api_authorization_config.json new file mode 100644 --- /dev/null +++ b/swh/web/settings/keycloak/swh_web_api_authorization_config.json @@ -0,0 +1,79 @@ +{ + "allowRemoteResourceManagement": true, + "policyEnforcementMode": "ENFORCING", + "resources": [ + { + "name": "Default Resource", + "type": "urn:swh-web-api:resources:default", + "ownerManagedAccess": false, + "attributes": {}, + "_id": "abe46b1c-dc86-41c9-981c-9daa6a31d352", + "uris": [ + "/*" + ] + } + ], + "policies": [ + { + "id": "25833801-9d89-4b35-a07c-65ff2523639c", + "name": "partner-user-policy", + "type": "role", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "roles": "[{\"id\":\"swh-web-api/partner-user\",\"required\":true}]" + } + }, + { + "id": "a23e8bda-21a8-4630-928d-444525fa95a7", + "name": "Default Policy", + "description": "A policy that grants access only for users within this realm", + "type": "js", + "logic": "POSITIVE", + "decisionStrategy": "AFFIRMATIVE", + "config": { + "code": "// by default, grants any permission associated with this policy\n$evaluation.grant();\n" + } + }, + { + "id": "39b6f617-1117-4c2c-b2f4-c9f1be3bdfec", + "name": "staff-user-policy", + "type": "role", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "roles": "[{\"id\":\"swh-web-api/staff-user\",\"required\":true}]" + } + }, + { + "id": "69ef5012-574f-46df-9189-3025864406ee", + "name": "throttling-exempted", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "scopes": "[\"api-calls\"]", + "applyPolicies": "[\"partner-user-policy\",\"staff-user-policy\"]" + } + }, + { + "id": "a5e43f4b-45aa-4ce4-9160-369f03f2df63", + "name": "Default Permission", + "description": "A permission that applies to the default resource type", + "type": "resource", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "defaultResourceType": "urn:swh-web-api:resources:default", + "applyPolicies": "[\"Default Policy\"]" + } + } + ], + "scopes": [ + { + "id": "179a8609-9dad-4498-bc0b-b6093aba1dd9", + "name": "api-calls" + } + ], + "decisionStrategy": "UNANIMOUS" +} \ No newline at end of file