diff --git a/cypress/integration/layout.spec.js b/cypress/integration/layout.spec.js --- a/cypress/integration/layout.spec.js +++ b/cypress/integration/layout.spec.js @@ -11,7 +11,7 @@ it('should should contain all navigation links', function() { cy.visit(url); cy.get('.swh-top-bar a') - .should('have.length', 4) + .should('have.length.of.at.least', 4) .and('be.visible') .and('have.attr', 'href'); }); diff --git a/mypy.ini b/mypy.ini --- a/mypy.ini +++ b/mypy.ini @@ -21,6 +21,9 @@ [mypy-htmlmin.*] ignore_missing_imports = True +[mypy-keycloak.*] +ignore_missing_imports = True + [mypy-magic.*] ignore_missing_imports = True diff --git a/requirements.txt b/requirements.txt --- a/requirements.txt +++ b/requirements.txt @@ -18,6 +18,7 @@ python-dateutil pyyaml requests +python-keycloak >= 0.19.0 python-memcached pybadges sentry-sdk @@ -26,5 +27,3 @@ # Doc dependencies sphinx sphinxcontrib-httpdomain - - diff --git a/swh/web/admin/urls.py b/swh/web/admin/urls.py --- a/swh/web/admin/urls.py +++ b/swh/web/admin/urls.py @@ -4,7 +4,7 @@ # See top-level LICENSE file for more information from django.conf.urls import url -from django.contrib.auth.views import LoginView, LogoutView +from django.contrib.auth.views import LoginView from django.shortcuts import redirect from swh.web.admin.adminurls import AdminUrls @@ -17,12 +17,11 @@ return redirect('admin-origin-save') -urlpatterns = [url(r'^$', _admin_default_view, name='admin'), - url(r'^login/$', - LoginView.as_view(template_name='login.html'), - name='login'), - url(r'^logout/$', - LogoutView.as_view(template_name='logout.html'), - name='logout')] +urlpatterns = [ + url(r'^$', _admin_default_view, name='admin'), + url(r'^login/$', + LoginView.as_view(template_name='login.html'), + name='login'), +] urlpatterns += AdminUrls.get_url_patterns() diff --git a/swh/web/assets/src/bundles/webapp/webapp.css b/swh/web/assets/src/bundles/webapp/webapp.css --- a/swh/web/assets/src/bundles/webapp/webapp.css +++ b/swh/web/assets/src/bundles/webapp/webapp.css @@ -295,6 +295,11 @@ color: #fecd1b; } +.swh-position-left { + position: absolute; + left: 0; +} + .swh-position-right { position: absolute; right: 0; diff --git a/swh/web/auth/__init__.py b/swh/web/auth/__init__.py new file mode 100644 diff --git a/swh/web/auth/backends.py b/swh/web/auth/backends.py new file mode 100644 --- /dev/null +++ b/swh/web/auth/backends.py @@ -0,0 +1,118 @@ +# Copyright (C) 2020 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 + +from datetime import datetime, timedelta +from typing import Any, Dict, Optional, Tuple + +from django.core.cache import cache +from django.http import HttpRequest +import sentry_sdk + +from swh.web.auth.keycloak import KeycloakOpenIDConnect +from swh.web.auth.utils import get_oidc_client +from swh.web.auth.models import OIDCUser + + +# OpenID Connect client to communicate with Keycloak server +_oidc_client: KeycloakOpenIDConnect = get_oidc_client() + + +def _oidc_user_from_info(userinfo: Dict[str, Any]) -> OIDCUser: + # compute an integer user identifier for Django User model + # by concatenating all groups of the UUID4 user identifier + # generated by Keycloak and converting it from hex to decimal + user_id = int(''.join(userinfo['sub'].split('-')), 16) + + # create a Django user that will not be saved to database + user = OIDCUser(id=user_id, + username=userinfo['preferred_username'], + password='', + first_name=userinfo['given_name'], + last_name=userinfo['family_name'], + email=userinfo['email']) + + # set is_staff user property based on groups + user.is_staff = '/staff' in userinfo['groups'] + + # add userinfo sub to custom User proxy model + user.sub = userinfo['sub'] + + return user + + +def _oidc_user_from_profile(oidc_profile: Dict[str, Any], + userinfo: Optional[Dict[str, Any]] = None + ) -> Tuple[OIDCUser, Dict[str, Any]]: + # get access token + access_token = oidc_profile['access_token'] + + # request OIDC userinfo + if userinfo is None: + userinfo = _oidc_client.userinfo(access_token) + + # create OIDCUser from userinfo + user = _oidc_user_from_info(userinfo) + + # decode JWT token + decoded_token = _oidc_client.decode_token(access_token) + + # get authentication init datetime + auth_datetime = datetime.fromtimestamp(decoded_token['auth_time']) + + # compute OIDC tokens expiration date + oidc_profile['access_expiration'] = ( + auth_datetime + + timedelta(seconds=oidc_profile['expires_in'])) + oidc_profile['refresh_expiration'] = ( + auth_datetime + + timedelta(seconds=oidc_profile['refresh_expires_in'])) + + # add OIDC profile data to custom User proxy model + for key, val in oidc_profile.items(): + if hasattr(user, key): + setattr(user, key, val) + + return user, userinfo + + +class OIDCAuthorizationCodePKCEBackend: + + def authenticate(self, request: HttpRequest, code: str, code_verifier: str, + redirect_uri: str) -> Optional[OIDCUser]: + + user = None + try: + # try to authenticate user with OIDC PKCE authorization code flow + oidc_profile = _oidc_client.authorization_code( + code, redirect_uri, code_verifier=code_verifier) + + # create Django user + user, userinfo = _oidc_user_from_profile(oidc_profile) + + # save authenticated user data in cache + cache.set(f'user_{user.id}', + {'userinfo': userinfo, 'oidc_profile': oidc_profile}, + timeout=oidc_profile['refresh_expires_in']) + except Exception as e: + sentry_sdk.capture_exception(e) + + return user + + def get_user(self, user_id: int) -> Optional[OIDCUser]: + # get user data from cache + user_oidc_data = cache.get(f'user_{user_id}') + if user_oidc_data: + try: + user, _ = _oidc_user_from_profile( + user_oidc_data['oidc_profile'], user_oidc_data['userinfo']) + # restore auth backend + setattr(user, 'backend', + f'{__name__}.{self.__class__.__name__}') + return user + except Exception as e: + sentry_sdk.capture_exception(e) + return None + else: + return None diff --git a/swh/web/auth/keycloak.py b/swh/web/auth/keycloak.py new file mode 100644 --- /dev/null +++ b/swh/web/auth/keycloak.py @@ -0,0 +1,162 @@ +# Copyright (C) 2020 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 + +from typing import Any, Dict, Optional, Tuple +from urllib.parse import urlencode + +from keycloak import KeycloakOpenID + + +class KeycloakOpenIDConnect: + """ + Wrapper class around python-keycloak to ease the interaction with Keycloak + for managing authentication and user permissions with OpenID Connect. + """ + + def __init__(self, server_url: str, realm_name: str, client_id: str, + realm_public_key: str = ''): + """ + Args: + server_url: URL of the Keycloak server + realm_name: The realm name + client_id: The OpenID Connect client identifier + realm_public_key: The realm public key (will be dynamically + retrieved if not provided) + """ + self._keycloak = KeycloakOpenID( + server_url=server_url, + client_id=client_id, + realm_name=realm_name, + ) + + self.server_url = server_url + self.realm_name = realm_name + self.client_id = client_id + self.realm_public_key = realm_public_key + + def well_known(self) -> Dict[str, Any]: + """ + Retrieve the OpenID Connect Well-Known URI registry from Keycloak. + + Returns: + A dictionary filled with OpenID Connect URIS. + """ + return self._keycloak.well_know() + + def authorization_url(self, redirect_uri: str, + **extra_params: str) -> str: + """ + Get OpenID Connect authorization URL to authenticate users. + + Args: + redirect_uri: URI to redirect to once a user is authenticated + extra_params: Extra query parameters to add to the + authorization URL + """ + auth_url = self._keycloak.auth_url(redirect_uri) + if extra_params: + auth_url += '&%s' % urlencode(extra_params) + return auth_url + + def authorization_code(self, code: str, redirect_uri: str, + **extra_params: str) -> Dict[str, Any]: + """ + Get OpenID Connect authentication tokens using Authorization + Code flow. + + Args: + code: Authorization code provided by Keycloak + redirect_uri: URI to redirect to once a user is authenticated + (must be the same as the one provided to authorization_url) + extra_params: Extra parameters to add in the authorization request + payload. + """ + return self._keycloak.token( + grant_type='authorization_code', + code=code, + redirect_uri=redirect_uri, + **extra_params) + + def refresh_token(self, refresh_token: str) -> Dict[str, Any]: + """ + Request a new access token from Keycloak using a refresh token. + + Args: + refresh_token: A refresh token provided by Keycloak + + Returns: + A dictionary filled with tokens info + """ + return self._keycloak.refresh_token(refresh_token) + + def decode_token(self, token: str, + options: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """ + Try to decode a JWT token. + + Args: + token: A JWT token to decode + options: Options for jose.jwt.decode + + Returns: + A dictionary filled with decoded token content + """ + if not self.realm_public_key: + realm_public_key = self._keycloak.public_key() + self.realm_public_key = '-----BEGIN PUBLIC KEY-----\n' + self.realm_public_key += realm_public_key + self.realm_public_key += '\n-----END PUBLIC KEY-----' + + return self._keycloak.decode_token(token, key=self.realm_public_key, + options=options) + + def logout(self, refresh_token: str) -> None: + """ + Logout a user by closing its authenticated session. + + Args: + refresh_token: A refresh token provided by Keycloak + """ + self._keycloak.logout(refresh_token) + + def userinfo(self, access_token: str) -> Dict[str, Any]: + """ + Return user information from its access token. + + Args: + access_token: An access token provided by Keycloak + + Returns: + A dictionary fillled with user information + """ + return self._keycloak.userinfo(access_token) + + +# stores instances of KeycloakOpenIDConnect class +# dict keys are (realm_name, client_id) tuples +_keycloak_oidc: Dict[Tuple[str, str], KeycloakOpenIDConnect] = {} + + +def get_keycloak_oidc_client(server_url: str, realm_name: str, + client_id: str) -> KeycloakOpenIDConnect: + """ + Instantiate a KeycloakOpenIDConnect class for a given client in a + given realm. + + Args: + server_url: Base URL of a Keycloak server + realm_name: Name of the realm in Keycloak + client_id: Client identifier in the realm + + Returns: + An object to ease the interaction with the Keycloak server + """ + realm_client_key = (realm_name, client_id) + if realm_client_key not in _keycloak_oidc: + _keycloak_oidc[realm_client_key] = KeycloakOpenIDConnect(server_url, + realm_name, + client_id) + return _keycloak_oidc[realm_client_key] diff --git a/swh/web/auth/models.py b/swh/web/auth/models.py new file mode 100644 --- /dev/null +++ b/swh/web/auth/models.py @@ -0,0 +1,43 @@ +# Copyright (C) 2020 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 + +from datetime import datetime +from typing import Optional + +from django.contrib.auth.models import User + + +class OIDCUser(User): + """ + Custom User proxy model for remote users storing OpenID Connect + related data: profile containing authorization tokens and userinfo. + + The model is also not saved to database as all users are already stored + in the Keycloak one. + """ + + # OIDC subject identifier + sub: str = '' + + # OIDC tokens and session related data, only relevant when a user + # authenticates from a web browser + access_token: Optional[str] = None + access_expiration: Optional[datetime] = None + id_token: Optional[str] = None + refresh_token: Optional[str] = None + refresh_expiration: Optional[datetime] = None + scope: Optional[str] = None + session_state: Optional[str] = None + + class Meta: + app_label = 'swh.web.auth' + proxy = True + + def save(self, **kwargs): + """ + Override django.db.models.Model.save to avoid saving the remote + users to web application database. + """ + pass diff --git a/swh/web/auth/utils.py b/swh/web/auth/utils.py new file mode 100644 --- /dev/null +++ b/swh/web/auth/utils.py @@ -0,0 +1,62 @@ +# Copyright (C) 2020 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 hashlib +import secrets + +from base64 import urlsafe_b64encode +from typing import Tuple + +from django.conf import settings + +from swh.web.auth.keycloak import ( + KeycloakOpenIDConnect, get_keycloak_oidc_client +) +from swh.web.config import get_config + + +def gen_oidc_pkce_codes() -> Tuple[str, str]: + """ + Generates a code verifier and a code challenge to be used + with the OpenID Connect authorization code flow with PKCE + ("Proof Key for Code Exchange", see https://tools.ietf.org/html/rfc7636). + + PKCE replaces the static secret used in the standard authorization + code flow with a temporary one-time challenge, making it feasible + to use in public clients. + + The implementation is inspired from that blog post: + https://www.stefaanlippens.net/oauth-code-flow-pkce.html + """ + # generate a code verifier which is a long enough random alphanumeric + # string, only to be used "client side" + code_verifier_str = secrets.token_urlsafe(60) + + # create the PKCE code challenge by hashing the code verifier with SHA256 + # and encoding the result in URL-safe base64 (without padding) + code_challenge = hashlib.sha256(code_verifier_str.encode('ascii')).digest() + code_challenge_str = urlsafe_b64encode(code_challenge).decode('ascii') + code_challenge_str = code_challenge_str.replace('=', '') + + return code_verifier_str, code_challenge_str + + +def get_oidc_client(client_id: str = '') -> KeycloakOpenIDConnect: + """ + Instantiate a KeycloakOpenIDConnect class for a given client in the + SoftwareHeritage realm. + + Args: + client_id: client identifier in the SoftwareHeritage realm + + Returns: + An object to ease the interaction with the Keycloak server + """ + if not client_id: + client_id = settings.OIDC_SWH_WEB_CLIENT_ID + swhweb_config = get_config() + return get_keycloak_oidc_client(swhweb_config['keycloak']['server_url'], + swhweb_config['keycloak']['realm_name'], + client_id) diff --git a/swh/web/auth/views.py b/swh/web/auth/views.py new file mode 100644 --- /dev/null +++ b/swh/web/auth/views.py @@ -0,0 +1,120 @@ +# Copyright (C) 2020 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 uuid + +from typing import cast + +from django.conf.urls import url +from django.core.cache import cache +from django.contrib.auth import authenticate, login, logout +from django.http import HttpRequest +from django.http.response import HttpResponse, HttpResponseRedirect + +from swh.web.auth.models import OIDCUser +from swh.web.auth.utils import gen_oidc_pkce_codes, get_oidc_client +from swh.web.common.exc import handle_view_exception, BadInputExc +from swh.web.common.utils import reverse + + +def oidc_login(request: HttpRequest) -> HttpResponse: + """ + Django view to initiate login process using OpenID Connect. + """ + # generate a CSRF token + state = str(uuid.uuid4()) + redirect_uri = reverse('oidc-login-complete', request=request) + + code_verifier, code_challenge = gen_oidc_pkce_codes() + + request.session['login_data'] = { + 'code_verifier': code_verifier, + 'state': state, + 'redirect_uri': redirect_uri, + 'next_path': request.GET.get('next_path'), + } + + authorization_url_params = { + 'state': state, + 'code_challenge': code_challenge, + 'code_challenge_method': 'S256', + 'scope': 'openid', + } + + try: + oidc_client = get_oidc_client() + authorization_url = oidc_client.authorization_url( + redirect_uri, **authorization_url_params) + + return HttpResponseRedirect(authorization_url) + except Exception as e: + return handle_view_exception(request, e) + + +def oidc_login_complete(request: HttpRequest) -> HttpResponse: + """ + Django view to finalize login process using OpenID Connect. + """ + try: + if 'login_data' not in request.session: + raise Exception('Login process has not been initialized.') + + if 'code' not in request.GET and 'state' not in request.GET: + raise BadInputExc('Missing query parameters for authentication.') + + # get CSRF token returned by OIDC server + state = request.GET['state'] + + login_data = request.session['login_data'] + + if state != login_data['state']: + raise BadInputExc('Wrong CSRF token, aborting login process.') + + user = authenticate(request=request, + code=request.GET['code'], + code_verifier=login_data['code_verifier'], + redirect_uri=login_data['redirect_uri']) + + if user is None: + raise Exception('User authentication failed.') + + login(request, user) + + redirect_url = (login_data['next_path'] or + request.build_absolute_uri('/')) + + return HttpResponseRedirect(redirect_url) + except Exception as e: + return handle_view_exception(request, e) + + +def oidc_logout(request: HttpRequest) -> HttpResponse: + """ + Django view to logout using OpenID Connect. + """ + try: + user = request.user + logout(request) + if hasattr(user, 'refresh_token'): + oidc_client = get_oidc_client() + user = cast(OIDCUser, user) + refresh_token = cast(str, user.refresh_token) + # end OpenID Connect session + oidc_client.logout(refresh_token) + # remove user data from cache + cache.delete(f'user_{user.id}') + + logout_url = reverse('logout', query_params={'remote_user': 1}) + return HttpResponseRedirect(request.build_absolute_uri(logout_url)) + except Exception as e: + return handle_view_exception(request, e) + + +urlpatterns = [ + url(r'^oidc/login/$', oidc_login, name='oidc-login'), + url(r'^oidc/login-complete/$', oidc_login_complete, + name='oidc-login-complete'), + url(r'^oidc/logout/$', oidc_logout, name='oidc-logout'), +] diff --git a/swh/web/common/utils.py b/swh/web/common/utils.py --- a/swh/web/common/utils.py +++ b/swh/web/common/utils.py @@ -343,10 +343,16 @@ Django context processor used to inject variables in all swh-web templates. """ + config = get_config() + if request.user.is_authenticated and not hasattr(request.user, 'backend'): + # To avoid django.template.base.VariableDoesNotExist errors + # when rendering templates when standard Django user is logged in. + request.user.backend = 'django.contrib.auth.backends.ModelBackend' return { 'swh_object_icons': swh_object_icons, 'available_languages': None, - 'swh_client_config': get_config()['client_config'], + 'swh_client_config': config['client_config'], + 'oidc_enabled': bool(config['keycloak']['server_url']), } diff --git a/swh/web/config.py b/swh/web/config.py --- a/swh/web/config.py +++ b/swh/web/config.py @@ -110,6 +110,10 @@ 'es_workers_index_url': ('string', ''), 'history_counters_url': ('string', 'https://stats.export.softwareheritage.org/history_counters.json'), # noqa 'client_config': ('dict', {}), + 'keycloak': ('dict', { + 'server_url': '', + 'realm_name': '' + }), } swhweb_config = {} # type: Dict[str, Any] 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,7 +47,7 @@ 'swh.web.browse', 'webpack_loader', 'django_js_reverse', - 'corsheaders' + 'corsheaders', ] MIDDLEWARE = [ @@ -59,7 +59,7 @@ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'swh.web.common.middlewares.ThrottlingHeadersMiddleware' + 'swh.web.common.middlewares.ThrottlingHeadersMiddleware', ] # Compress all assets (static ones and dynamically generated html) @@ -291,3 +291,10 @@ CORS_ORIGIN_ALLOW_ALL = True CORS_URLS_REGEX = r'^/badge/.*$' + +AUTHENTICATION_BACKENDS = [ + 'django.contrib.auth.backends.ModelBackend', + 'swh.web.auth.backends.OIDCAuthorizationCodePKCEBackend', +] + +OIDC_SWH_WEB_CLIENT_ID = 'swh-web' diff --git a/swh/web/settings/tests.py b/swh/web/settings/tests.py --- a/swh/web/settings/tests.py +++ b/swh/web/settings/tests.py @@ -80,7 +80,11 @@ 'exempted_networks': ['127.0.0.0/8'] } } - } + }, + 'keycloak': { + 'server_url': 'http://localhost:8080/auth', + 'realm_name': 'SoftwareHeritage', + }, }) diff --git a/swh/web/templates/layout.html b/swh/web/templates/layout.html --- a/swh/web/templates/layout.html +++ b/swh/web/templates/layout.html @@ -71,6 +71,11 @@