diff --git a/cypress/integration/layout.spec.js b/cypress/integration/layout.spec.js index f4d50eff..b4a77983 100644 --- a/cypress/integration/layout.spec.js +++ b/cypress/integration/layout.spec.js @@ -1,49 +1,49 @@ /** * 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 */ const url = '/'; describe('Test top-bar', function() { 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'); }); it('should show donate button on lg screen', function() { cy.visit(url); cy.get('.swh-donate-link') .should('be.visible'); }); it('should hide donate button on sm screen', function() { cy.viewport(600, 800); cy.visit(url); cy.get('.swh-donate-link') .should('not.be.visible'); }); }); describe('Test footer', function() { beforeEach(function() { cy.visit(url); }); it('should be visible', function() { cy.get('footer') .should('be.visible'); }); it('should have correct copyright years', function() { const currentYear = new Date().getFullYear(); const copyrightText = '(C) 2015–' + currentYear.toString(); cy.get('footer') .should('contain', copyrightText); }); }); diff --git a/mypy.ini b/mypy.ini index b51bd7de..e5d71724 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,49 +1,52 @@ [mypy] namespace_packages = True warn_unused_ignores = True # support for django magic: https://github.com/typeddjango/django-stubs plugins = mypy_django_plugin.main, mypy_drf_plugin.main [mypy.plugins.django-stubs] django_settings_module = swh.web.settings.development # 3rd party libraries without stubs (yet) [mypy-bs4.*] ignore_missing_imports = True [mypy-corsheaders.*] ignore_missing_imports = True [mypy-django_js_reverse.*] ignore_missing_imports = True [mypy-htmlmin.*] ignore_missing_imports = True +[mypy-keycloak.*] +ignore_missing_imports = True + [mypy-magic.*] ignore_missing_imports = True [mypy-pkg_resources.*] ignore_missing_imports = True [mypy-prometheus_client.*] ignore_missing_imports = True [mypy-pygments.*] ignore_missing_imports = True [mypy-pytest.*] ignore_missing_imports = True [mypy-requests_mock.*] ignore_missing_imports = True [mypy-sphinx.*] ignore_missing_imports = True [mypy-sphinxcontrib.*] ignore_missing_imports = True [mypy-swh.docs.*] ignore_missing_imports = True diff --git a/requirements.txt b/requirements.txt index a68201b3..f5aa8050 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,30 +1,29 @@ # Add here external Python modules dependencies, one per line. Module names # should match https://pypi.python.org/pypi names. For the full spec or # dependency lines, see https://pip.readthedocs.org/en/1.1/requirements.html # Runtime dependencies beautifulsoup4 django < 3 django-cors-headers djangorestframework django_webpack_loader django_js_reverse docutils python-magic >= 0.4.0 htmlmin lxml prometheus_client pygments python-dateutil pyyaml requests +python-keycloak >= 0.19.0 python-memcached pybadges sentry-sdk typing-extensions # Doc dependencies sphinx sphinxcontrib-httpdomain - - diff --git a/swh/web/admin/urls.py b/swh/web/admin/urls.py index d7c38f17..008062a9 100644 --- a/swh/web/admin/urls.py +++ b/swh/web/admin/urls.py @@ -1,28 +1,27 @@ # Copyright (C) 2018 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 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 import swh.web.admin.origin_save # noqa import swh.web.admin.deposit # noqa def _admin_default_view(request): 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 index e0a96f85..9b57d36b 100644 --- a/swh/web/assets/src/bundles/webapp/webapp.css +++ b/swh/web/assets/src/bundles/webapp/webapp.css @@ -1,615 +1,620 @@ /** * Copyright (C) 2018-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 */ html { height: 100%; overflow-x: hidden; scroll-behavior: auto !important; } body { min-height: 100%; margin: 0; position: relative; padding-bottom: 120px; } a:active, a.active { outline: none; } code { background-color: #f9f2f4; } pre code { background-color: transparent; } footer { background-color: #262626; color: #fff; font-size: 0.8rem; position: absolute; bottom: 0; width: 100%; padding-top: 20px; padding-bottom: 20px; } footer a, footer a:visited, footer a:hover { color: #fecd1b; } footer a:hover { text-decoration: underline; } .link-color { color: #fecd1b; } pre { background-color: #f5f5f5; border: 1px solid #ccc; border-radius: 4px; padding: 9.5px; font-size: 0.8rem; } .btn.active { background-color: #e7e7e7; } .card { margin-bottom: 5px !important; overflow-x: auto; } .navbar-brand { padding: 5px; margin-right: 0; } .table { margin-bottom: 0; } .swh-table thead { background-color: #f2f4f5; border-top: 1px solid rgba(0, 0, 0, 0.2); font-weight: normal; } .swh-table-striped th { border-top: none; } .swh-table-striped tbody tr:nth-child(even) { background-color: #f2f4f5; } .swh-table-striped tbody tr:nth-child(odd) { background-color: #fff; } .swh-web-app-link a { text-decoration: none; border: none; } .swh-web-app-link:hover { background-color: #efeff2; } .table > thead > tr > th { border-top: none; border-bottom: 1px solid #e20026; } .table > tbody > tr > td { border-style: none; } .sitename .first-word, .sitename .second-word { color: rgba(0, 0, 0, 0.75); font-weight: normal; font-size: 1.2rem; } .sitename .first-word { font-family: 'Alegreya Sans', sans-serif; } .sitename .second-word { font-family: 'Alegreya', serif; } .swh-counter { font-size: 150%; } @media (max-width: 600px) { .swh-counter-container { margin-top: 1rem; } } .swh-http-error { margin: 0 auto; text-align: center; } .swh-http-error-head { color: #2d353c; font-size: 30px; } .swh-http-error-code { bottom: 60%; color: #2d353c; font-size: 96px; line-height: 80px; margin-bottom: 10px !important; } .swh-http-error-desc { font-size: 12px; color: #647788; text-align: center; } .swh-http-error-desc pre { display: inline-block; text-align: left; max-width: 800px; white-space: pre-wrap; } .popover { max-width: 97%; z-index: 40000; } .modal { text-align: center; padding: 0 !important; z-index: 50000; } .modal::before { content: ''; display: inline-block; height: 100%; vertical-align: middle; margin-right: -4px; } .modal-dialog { display: inline-block; text-align: left; vertical-align: middle; } .dropdown-submenu { position: relative; } .dropdown-submenu .dropdown-menu { top: 0; left: -100%; margin-top: -5px; margin-left: -2px; } .dropdown-item:hover, .dropdown-item:focus { background-color: rgba(0, 0, 0, 0.1); } a.dropdown-left::before { content: "\f0d9"; font-family: 'FontAwesome'; display: block; width: 20px; height: 20px; float: left; margin-left: 0; } #swh-navbar { border-top-style: none; border-left-style: none; border-right-style: none; border-bottom-style: solid; border-bottom-width: 5px; border-image: linear-gradient(to right, rgb(226, 0, 38) 0%, rgb(254, 205, 27) 100%) 1 1 1 1; width: 100%; padding: 5px; margin-bottom: 10px; margin-top: 30px; justify-content: normal; flex-wrap: nowrap; height: 72px; overflow: hidden; } #back-to-top { display: none; position: fixed; bottom: 30px; right: 30px; z-index: 10; } #back-to-top a img { display: block; width: 32px; height: 32px; background-size: 32px 32px; text-indent: -999px; overflow: hidden; } .swh-top-bar { direction: ltr; height: 30px; position: fixed; top: 0; left: 0; width: 100%; z-index: 99999; background-color: #262626; color: #fff; text-align: center; font-size: 14px; } .swh-top-bar ul { margin-top: 4px; padding-left: 0; white-space: nowrap; } .swh-top-bar li { display: inline-block; margin-left: 10px; margin-right: 10px; } .swh-top-bar a, .swh-top-bar a:visited { color: white; } .swh-top-bar a.swh-current-site, .swh-top-bar a.swh-current-site:visited { color: #fecd1b; } +.swh-position-left { + position: absolute; + left: 0; +} + .swh-position-right { position: absolute; right: 0; } .swh-donate-link { border: 1px solid #fecd1b; background-color: #e20026; color: white !important; padding: 3px; border-radius: 3px; } .swh-navbar-content h4 { padding-top: 7px; } .swh-navbar-content .bread-crumbs { display: block; margin-left: -40px; } .swh-navbar-content .bread-crumbs li.bc-no-root { padding-top: 7px; } .main-sidebar { margin-top: 30px; } .content-wrapper { background: none; } .brand-image { max-height: 40px; } .brand-link { padding-top: 18.5px; padding-bottom: 18px; padding-left: 4px; border-bottom: 5px solid #e20026 !important; } .navbar-header a, ul.dropdown-menu a, ul.navbar-nav a, ul.nav-sidebar a { border-bottom-style: none; color: #323232; } .swh-sidebar .nav-link.active { color: #323232 !important; background-color: #e7e7e7 !important; } .swh-image-error { width: 80px; height: auto; } @media (max-width: 600px) { .card { min-width: 80%; } .swh-image-error { width: 40px; height: auto; } .swh-navbar-content h4 { font-size: 1rem; } .swh-donate-link { display: none; } } .form-check-label { padding-top: 4px; } .swh-id-option { display: inline-block; margin-right: 5px; line-height: 1rem; } .nav-pills .nav-link:not(.active):hover { color: rgba(0, 0, 0, 0.55); } .swh-heading-color { color: #e20026 !important; } .sidebar-mini.sidebar-collapse .main-sidebar:hover { width: 4.6rem; } .sidebar-mini.sidebar-collapse .main-sidebar:hover .user-panel > .info, .sidebar-mini.sidebar-collapse .main-sidebar:hover .nav-sidebar .nav-link p, .sidebar-mini.sidebar-collapse .main-sidebar:hover .brand-text { visibility: hidden !important; } .sidebar .nav-link p, .main-sidebar .brand-text, .sidebar .user-panel .info { transition: none; } .sidebar-mini.sidebar-mini.sidebar-collapse .sidebar { padding-right: 0; } .swh-words-logo { position: absolute; top: 0; left: 0; width: 73px; height: 73px; text-align: center; font-size: 10pt; color: rgba(0, 0, 0, 0.75); } .swh-words-logo:hover { text-decoration: none; } .swh-words-logo-swh { line-height: 1; padding-top: 13px; visibility: hidden; } hr.swh-faded-line { border: 0; height: 1px; background-image: linear-gradient(to left, #f0f0f0, #8c8b8b, #f0f0f0); } /* Ensure that section title with link is colored like standard section title */ .swh-readme h1 a, .swh-readme h2 a, .swh-readme h3 a, .swh-readme h4 a, .swh-readme h5 a, .swh-readme h6 a { color: #e20026; } /* Make list compact in reStructuredText rendering */ .swh-rst li p { margin-bottom: 0; } .swh-readme-txt pre { background: none; border: none; } .swh-coverage-col { padding-left: 10px; padding-right: 10px; } .swh-coverage { height: calc(65px + 1em); padding-top: 0.3rem; border: none; } .swh-coverage a { text-decoration: none; } .swh-coverage-logo { display: block; width: 100%; height: 50px; margin-left: auto; margin-right: auto; object-fit: contain; /* polyfill for old browsers, see https://github.com/bfred-it/object-fit-images */ font-family: 'object-fit: contain;'; } .swh-coverage-list { width: 100%; height: 320px; border: none; } tr.swh-tr-hover-highlight:hover td { background: #ededed; } tr.swh-api-doc-route a { text-decoration: none; } .swh-apidoc .col { margin: 10px; } .swh-apidoc .swh-rst blockquote { border: 0; margin: 0; padding: 0; } a.toggle-col { text-decoration: none; } a.toggle-col.col-hidden { text-decoration: line-through; } .admonition.warning { background: #fcf8e3; border: 1px solid #faebcc; padding: 15px; border-radius: 4px; } .admonition.warning p { margin-bottom: 0; } .admonition.warning .first { font-size: 1.5rem; } .swh-popover { max-height: 50vh; overflow-y: auto; overflow-x: auto; padding: 0; padding-right: 1.4em; } @media screen and (min-width: 768px) { .swh-popover { max-width: 50vw; } } .swh-metadata-table-row { border-top: 1px solid #ddd !important; } .swh-metadata-table-key { min-width: 200px; max-width: 200px; width: 200px; } .swh-metadata-table-value pre { white-space: pre-wrap; } .d3-wrapper { position: relative; height: 0; width: 100%; padding: 0; /* padding-bottom will be overwritten by JavaScript later */ padding-bottom: 100%; } .d3-wrapper > svg { position: absolute; height: 100%; width: 100%; left: 0; top: 0; } div.d3-tooltip { position: absolute; text-align: center; width: auto; height: auto; padding: 2px; font: 12px sans-serif; background: white; border: 1px solid black; border-radius: 4px; pointer-events: none; } .page-link { cursor: pointer; } .wrapper { overflow: hidden; } .swh-badge { padding-bottom: 1rem; cursor: pointer; } .swh-badge-html, .swh-badge-md, .swh-badge-rst { white-space: pre-wrap; } diff --git a/swh/web/auth/__init__.py b/swh/web/auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/swh/web/auth/backends.py b/swh/web/auth/backends.py new file mode 100644 index 00000000..09758814 --- /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 index 00000000..bf4c13fc --- /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 index 00000000..53f4be24 --- /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 index 00000000..4c561b35 --- /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 index 00000000..f48e9c34 --- /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 index c0bfd76a..474693ee 100644 --- a/swh/web/common/utils.py +++ b/swh/web/common/utils.py @@ -1,494 +1,500 @@ # Copyright (C) 2017-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 re from datetime import datetime, timezone from dateutil import parser as date_parser from dateutil import tz from typing import Optional, Dict, Any import docutils.parsers.rst import docutils.utils from bs4 import BeautifulSoup from docutils.core import publish_parts from docutils.writers.html5_polyglot import Writer, HTMLTranslator from django.urls import reverse as django_reverse from django.http import QueryDict, HttpRequest from prometheus_client.registry import CollectorRegistry from rest_framework.authentication import SessionAuthentication from swh.model.exceptions import ValidationError from swh.model.hashutil import hash_to_bytes from swh.model.identifiers import ( persistent_identifier, parse_persistent_identifier, CONTENT, DIRECTORY, ORIGIN, RELEASE, REVISION, SNAPSHOT ) from swh.web.common.exc import BadInputExc from swh.web.config import get_config SWH_WEB_METRICS_REGISTRY = CollectorRegistry(auto_describe=True) swh_object_icons = { 'branch': 'fa fa-code-fork', 'branches': 'fa fa-code-fork', 'content': 'fa fa-file-text', 'directory': 'fa fa-folder', 'person': 'fa fa-user', 'revisions history': 'fa fa-history', 'release': 'fa fa-tag', 'releases': 'fa fa-tag', 'revision': 'octicon-git-commit', 'snapshot': 'fa fa-camera', 'visits': 'fa fa-calendar', } def reverse(viewname: str, url_args: Optional[Dict[str, Any]] = None, query_params: Optional[Dict[str, Any]] = None, current_app: Optional[str] = None, urlconf: Optional[str] = None, request: Optional[HttpRequest] = None) -> str: """An override of django reverse function supporting query parameters. Args: viewname: the name of the django view from which to compute a url url_args: dictionary of url arguments indexed by their names query_params: dictionary of query parameters to append to the reversed url current_app: the name of the django app tighten to the view urlconf: url configuration module request: build an absolute URI if provided Returns: str: the url of the requested view with processed arguments and query parameters """ if url_args: url_args = {k: v for k, v in url_args.items() if v is not None} url = django_reverse(viewname, urlconf=urlconf, kwargs=url_args, current_app=current_app) if query_params: query_params = {k: v for k, v in query_params.items() if v} if query_params and len(query_params) > 0: query_dict = QueryDict('', mutable=True) for k in sorted(query_params.keys()): query_dict[k] = query_params[k] url += ('?' + query_dict.urlencode(safe='/;:')) if request is not None: url = request.build_absolute_uri(url) return url def datetime_to_utc(date): """Returns datetime in UTC without timezone info Args: date (datetime.datetime): input datetime with timezone info Returns: datetime.datetime: datetime in UTC without timezone info """ if date.tzinfo: return date.astimezone(tz.gettz('UTC')).replace(tzinfo=timezone.utc) else: return date def parse_timestamp(timestamp): """Given a time or timestamp (as string), parse the result as UTC datetime. Returns: datetime.datetime: a timezone-aware datetime representing the parsed value or None if the parsing fails. Samples: - 2016-01-12 - 2016-01-12T09:19:12+0100 - Today is January 1, 2047 at 8:21:00AM - 1452591542 """ if not timestamp: return None try: date = date_parser.parse(timestamp, ignoretz=False, fuzzy=True) return datetime_to_utc(date) except Exception: try: return datetime.utcfromtimestamp(float(timestamp)).replace( tzinfo=timezone.utc) except (ValueError, OverflowError) as e: raise BadInputExc(e) def shorten_path(path): """Shorten the given path: for each hash present, only return the first 8 characters followed by an ellipsis""" sha256_re = r'([0-9a-f]{8})[0-9a-z]{56}' sha1_re = r'([0-9a-f]{8})[0-9a-f]{32}' ret = re.sub(sha256_re, r'\1...', path) return re.sub(sha1_re, r'\1...', ret) def format_utc_iso_date(iso_date, fmt='%d %B %Y, %H:%M UTC'): """Turns a string representation of an ISO 8601 date string to UTC and format it into a more human readable one. For instance, from the following input string: '2017-05-04T13:27:13+02:00' the following one is returned: '04 May 2017, 11:27 UTC'. Custom format string may also be provided as parameter Args: iso_date (str): a string representation of an ISO 8601 date fmt (str): optional date formatting string Returns: str: a formatted string representation of the input iso date """ if not iso_date: return iso_date date = parse_timestamp(iso_date) return date.strftime(fmt) def gen_path_info(path): """Function to generate path data navigation for use with a breadcrumb in the swh web ui. For instance, from a path /folder1/folder2/folder3, it returns the following list:: [{'name': 'folder1', 'path': 'folder1'}, {'name': 'folder2', 'path': 'folder1/folder2'}, {'name': 'folder3', 'path': 'folder1/folder2/folder3'}] Args: path: a filesystem path Returns: list: a list of path data for navigation as illustrated above. """ path_info = [] if path: sub_paths = path.strip('/').split('/') path_from_root = '' for p in sub_paths: path_from_root += '/' + p path_info.append({'name': p, 'path': path_from_root.strip('/')}) return path_info def get_swh_persistent_id(object_type, object_id, scheme_version=1): """ Returns the persistent identifier for a swh object based on: * the object type * the object id * the swh identifiers scheme version Args: object_type (str): the swh object type (content/directory/release/revision/snapshot) object_id (str): the swh object id (hexadecimal representation of its hash value) scheme_version (int): the scheme version of the swh persistent identifiers Returns: str: the swh object persistent identifier Raises: BadInputExc: if the provided parameters do not enable to generate a valid identifier """ try: swh_id = persistent_identifier(object_type, object_id, scheme_version) except ValidationError as e: raise BadInputExc('Invalid object (%s) for swh persistent id. %s' % (object_id, e)) else: return swh_id def resolve_swh_persistent_id(swh_id, query_params=None): """ Try to resolve a Software Heritage persistent id into an url for browsing the pointed object. Args: swh_id (str): a Software Heritage persistent identifier query_params (django.http.QueryDict): optional dict filled with query parameters to append to the browse url Returns: dict: a dict with the following keys: * **swh_id_parsed (swh.model.identifiers.PersistentId)**: the parsed identifier * **browse_url (str)**: the url for browsing the pointed object """ swh_id_parsed = get_persistent_identifier(swh_id) object_type = swh_id_parsed.object_type object_id = swh_id_parsed.object_id browse_url = None query_dict = QueryDict('', mutable=True) if query_params and len(query_params) > 0: for k in sorted(query_params.keys()): query_dict[k] = query_params[k] if 'origin' in swh_id_parsed.metadata: query_dict['origin'] = swh_id_parsed.metadata['origin'] if object_type == CONTENT: query_string = 'sha1_git:' + object_id fragment = '' if 'lines' in swh_id_parsed.metadata: lines = swh_id_parsed.metadata['lines'].split('-') fragment += '#L' + lines[0] if len(lines) > 1: fragment += '-L' + lines[1] browse_url = reverse('browse-content', url_args={'query_string': query_string}, query_params=query_dict) + fragment elif object_type == DIRECTORY: browse_url = reverse('browse-directory', url_args={'sha1_git': object_id}, query_params=query_dict) elif object_type == RELEASE: browse_url = reverse('browse-release', url_args={'sha1_git': object_id}, query_params=query_dict) elif object_type == REVISION: browse_url = reverse('browse-revision', url_args={'sha1_git': object_id}, query_params=query_dict) elif object_type == SNAPSHOT: browse_url = reverse('browse-snapshot', url_args={'snapshot_id': object_id}, query_params=query_dict) elif object_type == ORIGIN: raise BadInputExc(('Origin PIDs (Persistent Identifiers) are not ' 'publicly resolvable because they are for ' 'internal usage only')) return {'swh_id_parsed': swh_id_parsed, 'browse_url': browse_url} def parse_rst(text, report_level=2): """ Parse a reStructuredText string with docutils. Args: text (str): string with reStructuredText markups in it report_level (int): level of docutils report messages to print (1 info 2 warning 3 error 4 severe 5 none) Returns: docutils.nodes.document: a parsed docutils document """ parser = docutils.parsers.rst.Parser() components = (docutils.parsers.rst.Parser,) settings = docutils.frontend.OptionParser( components=components).get_default_values() settings.report_level = report_level document = docutils.utils.new_document('rst-doc', settings=settings) parser.parse(text, document) return document def get_client_ip(request): """ Return the client IP address from an incoming HTTP request. Args: request (django.http.HttpRequest): the incoming HTTP request Returns: str: The client IP address """ x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') if x_forwarded_for: ip = x_forwarded_for.split(',')[0] else: ip = request.META.get('REMOTE_ADDR') return ip def context_processor(request): """ 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']), } class EnforceCSRFAuthentication(SessionAuthentication): """ Helper class to enforce CSRF validation on a DRF view when a user is not authenticated. """ def authenticate(self, request): user = getattr(request._request, 'user', None) self.enforce_csrf(request) return (user, None) def resolve_branch_alias(snapshot: Dict[str, Any], branch: Optional[Dict[str, Any]] ) -> Optional[Dict[str, Any]]: """ Resolve branch alias in snapshot content. Args: snapshot: a full snapshot content branch: a branch alias contained in the snapshot Returns: The real snapshot branch that got aliased. """ while branch and branch['target_type'] == 'alias': if branch['target'] in snapshot['branches']: branch = snapshot['branches'][branch['target']] else: from swh.web.common import service snp = service.lookup_snapshot( snapshot['id'], branches_from=branch['target'], branches_count=1) if snp and branch['target'] in snp['branches']: branch = snp['branches'][branch['target']] else: branch = None return branch def get_persistent_identifier(persistent_id): """Check if a persistent identifier is valid. Args: persistent_id: A string representing a Software Heritage persistent identifier. Raises: BadInputExc: if the provided persistent identifier can not be parsed. Return: A persistent identifier object. """ try: pid_object = parse_persistent_identifier(persistent_id) except ValidationError as ve: raise BadInputExc('Error when parsing identifier: %s' % ' '.join(ve.messages)) else: return pid_object def group_swh_persistent_identifiers(persistent_ids): """ Groups many Software Heritage persistent identifiers into a dictionary depending on their type. Args: persistent_ids (list): a list of Software Heritage persistent identifier objects Returns: A dictionary with: keys: persistent identifier types values: list(bytes) persistent identifiers id Raises: BadInputExc: if one of the provided persistent identifier can not be parsed. """ pids_by_type = { CONTENT: [], DIRECTORY: [], REVISION: [], RELEASE: [], SNAPSHOT: [] } for pid in persistent_ids: obj_id = pid.object_id obj_type = pid.object_type pids_by_type[obj_type].append(hash_to_bytes(obj_id)) return pids_by_type class _NoHeaderHTMLTranslator(HTMLTranslator): """ Docutils translator subclass to customize the generation of HTML from reST-formatted docstrings """ def __init__(self, document): super().__init__(document) self.body_prefix = [] self.body_suffix = [] _HTML_WRITER = Writer() _HTML_WRITER.translator_class = _NoHeaderHTMLTranslator def rst_to_html(rst: str) -> str: """ Convert reStructuredText document into HTML. Args: rst: A string containing a reStructuredText document Returns: Body content of the produced HTML conversion. """ settings = { 'initial_header_level': 2, } pp = publish_parts(rst, writer=_HTML_WRITER, settings_overrides=settings) return f'
{pp["html_body"]}
' def prettify_html(html: str) -> str: """ Prettify an HTML document. Args: html: Input HTML document Returns: The prettified HTML document """ return BeautifulSoup(html, 'lxml').prettify() diff --git a/swh/web/config.py b/swh/web/config.py index 2a5cb790..37113dbc 100644 --- a/swh/web/config.py +++ b/swh/web/config.py @@ -1,184 +1,188 @@ # Copyright (C) 2017-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 os from typing import Any, Dict from swh.core import config from swh.indexer.storage import get_indexer_storage from swh.scheduler import get_scheduler from swh.search import get_search from swh.storage import get_storage from swh.vault import get_vault from swh.web import settings SETTINGS_DIR = os.path.dirname(settings.__file__) DEFAULT_CONFIG = { 'allowed_hosts': ('list', []), 'search': ('dict', { 'cls': 'remote', 'args': { 'url': 'http://127.0.0.1:5010/', 'timeout': 10, }, }), 'storage': ('dict', { 'cls': 'remote', 'url': 'http://127.0.0.1:5002/', 'timeout': 10, }), 'indexer_storage': ('dict', { 'cls': 'remote', 'args': { 'url': 'http://127.0.0.1:5007/', 'timeout': 1, } }), 'log_dir': ('string', '/tmp/swh/log'), 'debug': ('bool', False), 'serve_assets': ('bool', False), 'host': ('string', '127.0.0.1'), 'port': ('int', 5004), 'secret_key': ('string', 'development key'), # do not display code highlighting for content > 1MB 'content_display_max_size': ('int', 5 * 1024 * 1024), 'snapshot_content_max_size': ('int', 1000), 'throttling': ('dict', { 'cache_uri': None, # production: memcached as cache (127.0.0.1:11211) # development: in-memory cache so None 'scopes': { 'swh_api': { 'limiter_rate': { 'default': '120/h' }, 'exempted_networks': ['127.0.0.0/8'] }, 'swh_api_origin_search': { 'limiter_rate': { 'default': '10/m' }, 'exempted_networks': ['127.0.0.0/8'] }, 'swh_vault_cooking': { 'limiter_rate': { 'default': '120/h', 'GET': '60/m' }, 'exempted_networks': ['127.0.0.0/8'] }, 'swh_save_origin': { 'limiter_rate': { 'default': '120/h', 'POST': '10/h' }, 'exempted_networks': ['127.0.0.0/8'] }, 'swh_api_origin_visit_latest': { 'limiter_rate': { 'default': '700/m' }, 'exempted_networks': ['127.0.0.0/8'], }, } }), 'vault': ('dict', { 'cls': 'remote', 'args': { 'url': 'http://127.0.0.1:5005/', } }), 'scheduler': ('dict', { 'cls': 'remote', 'args': { 'url': 'http://127.0.0.1:5008/' } }), 'development_db': ('string', os.path.join(SETTINGS_DIR, 'db.sqlite3')), 'test_db': ('string', os.path.join(SETTINGS_DIR, 'testdb.sqlite3')), 'production_db': ('string', '/var/lib/swh/web.sqlite3'), 'deposit': ('dict', { 'private_api_url': 'https://deposit.softwareheritage.org/1/private/', 'private_api_user': 'swhworker', 'private_api_password': '' }), 'coverage_count_origins': ('bool', False), 'e2e_tests_mode': ('bool', False), '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] def get_config(config_file='web/web'): """Read the configuration file `config_file`. If an environment variable SWH_CONFIG_FILENAME is defined, this takes precedence over the config_file parameter. In any case, update the app with parameters (secret_key, conf) and return the parsed configuration as a dict. If no configuration file is provided, return a default configuration. """ if not swhweb_config: config_filename = os.environ.get('SWH_CONFIG_FILENAME') if config_filename: config_file = config_filename cfg = config.load_named_config(config_file, DEFAULT_CONFIG) swhweb_config.update(cfg) config.prepare_folders(swhweb_config, 'log_dir') if swhweb_config.get('search'): swhweb_config['search'] = get_search(**swhweb_config['search']) else: swhweb_config['search'] = None swhweb_config['storage'] = get_storage(**swhweb_config['storage']) swhweb_config['vault'] = get_vault(**swhweb_config['vault']) swhweb_config['indexer_storage'] = \ get_indexer_storage(**swhweb_config['indexer_storage']) swhweb_config['scheduler'] = get_scheduler( **swhweb_config['scheduler']) return swhweb_config def search(): """Return the current application's search. """ return get_config()['search'] def storage(): """Return the current application's storage. """ return get_config()['storage'] def vault(): """Return the current application's vault. """ return get_config()['vault'] def indexer_storage(): """Return the current application's indexer storage. """ return get_config()['indexer_storage'] def scheduler(): """Return the current application's scheduler. """ return get_config()['scheduler'] diff --git a/swh/web/settings/common.py b/swh/web/settings/common.py index 9126eced..2af35d09 100644 --- a/swh/web/settings/common.py +++ b/swh/web/settings/common.py @@ -1,293 +1,300 @@ # Copyright (C) 2017-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 """ Django common settings for swh-web. """ import os import sys from typing import Any, Dict from swh.web.config import get_config swh_web_config = get_config() # Build paths inside the project like this: os.path.join(BASE_DIR, ...) PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = swh_web_config['secret_key'] # SECURITY WARNING: don't run with debug turned on in production! DEBUG = swh_web_config['debug'] DEBUG_PROPAGATE_EXCEPTIONS = swh_web_config['debug'] ALLOWED_HOSTS = ['127.0.0.1', 'localhost'] + swh_web_config['allowed_hosts'] # Application definition INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'rest_framework', 'swh.web.common', 'swh.web.api', 'swh.web.browse', 'webpack_loader', 'django_js_reverse', - 'corsheaders' + 'corsheaders', ] MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'corsheaders.middleware.CorsMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', '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) # served by django in a local development environment context. # In a production environment, assets compression will be directly # handled by web servers like apache or nginx. if swh_web_config['serve_assets']: MIDDLEWARE.insert(0, 'django.middleware.gzip.GZipMiddleware') ROOT_URLCONF = 'swh.web.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', 'DIRS': [os.path.join(PROJECT_DIR, "../templates")], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ 'django.template.context_processors.debug', 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', 'swh.web.common.utils.context_processor' ], 'libraries': { 'swh_templatetags': 'swh.web.common.swh_templatetags', }, }, }, ] DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': swh_web_config['development_db'], } } # Password validation # https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', # noqa }, { 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', # noqa }, { 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', # noqa }, { 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', # noqa }, ] # Internationalization # https://docs.djangoproject.com/en/1.11/topics/i18n/ LANGUAGE_CODE = 'en-us' TIME_ZONE = 'UTC' USE_I18N = True USE_L10N = True USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.11/howto/static-files/ STATIC_URL = '/static/' # static folder location when swh-web has been installed with pip STATIC_DIR = os.path.join(sys.prefix, 'share/swh/web/static') if not os.path.exists(STATIC_DIR): # static folder location when developping swh-web STATIC_DIR = os.path.join(PROJECT_DIR, '../../../static') STATICFILES_DIRS = [STATIC_DIR] INTERNAL_IPS = ['127.0.0.1'] throttle_rates = {} http_requests = ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'] throttling = swh_web_config['throttling'] for limiter_scope, limiter_conf in throttling['scopes'].items(): if 'default' in limiter_conf['limiter_rate']: throttle_rates[limiter_scope] = limiter_conf['limiter_rate']['default'] # for backward compatibility else: throttle_rates[limiter_scope] = limiter_conf['limiter_rate'] # register sub scopes specific for HTTP request types for http_request in http_requests: if http_request in limiter_conf['limiter_rate']: throttle_rates[limiter_scope + '_' + http_request.lower()] = \ limiter_conf['limiter_rate'][http_request] REST_FRAMEWORK: Dict[str, Any] = { 'DEFAULT_RENDERER_CLASSES': ( 'rest_framework.renderers.JSONRenderer', 'swh.web.api.renderers.YAMLRenderer', 'rest_framework.renderers.TemplateHTMLRenderer' ), 'DEFAULT_THROTTLE_CLASSES': ( 'swh.web.common.throttling.SwhWebRateThrottle', ), 'DEFAULT_THROTTLE_RATES': throttle_rates } LOGGING = { 'version': 1, 'disable_existing_loggers': False, 'filters': { 'require_debug_false': { '()': 'django.utils.log.RequireDebugFalse', }, 'require_debug_true': { '()': 'django.utils.log.RequireDebugTrue', }, }, 'formatters': { 'request': { 'format': '[%(asctime)s] [%(levelname)s] %(request)s %(status_code)s', # noqa 'datefmt': "%d/%b/%Y %H:%M:%S" }, 'simple': { 'format': '[%(asctime)s] [%(levelname)s] %(message)s', 'datefmt': "%d/%b/%Y %H:%M:%S" }, 'verbose': { 'format': '[%(asctime)s] [%(levelname)s] %(name)s.%(funcName)s:%(lineno)s - %(message)s', # noqa 'datefmt': "%d/%b/%Y %H:%M:%S" }, }, 'handlers': { 'console': { 'level': 'DEBUG', 'filters': ['require_debug_true'], 'class': 'logging.StreamHandler', 'formatter': 'simple' }, 'file': { 'level': 'WARNING', 'filters': ['require_debug_false'], 'class': 'logging.FileHandler', 'filename': os.path.join(swh_web_config['log_dir'], 'swh-web.log'), 'formatter': 'simple' }, 'file_request': { 'level': 'WARNING', 'filters': ['require_debug_false'], 'class': 'logging.FileHandler', 'filename': os.path.join(swh_web_config['log_dir'], 'swh-web.log'), 'formatter': 'request' }, 'console_verbose': { 'level': 'DEBUG', 'filters': ['require_debug_true'], 'class': 'logging.StreamHandler', 'formatter': 'verbose' }, 'file_verbose': { 'level': 'WARNING', 'filters': ['require_debug_false'], 'class': 'logging.FileHandler', 'filename': os.path.join(swh_web_config['log_dir'], 'swh-web.log'), 'formatter': 'verbose' }, 'null': { 'class': 'logging.NullHandler', }, }, 'loggers': { '': { 'handlers': ['console_verbose', 'file_verbose'], 'level': 'DEBUG' if DEBUG else 'WARNING', }, 'django': { 'handlers': ['console'], 'level': 'DEBUG' if DEBUG else 'WARNING', 'propagate': False, }, 'django.request': { 'handlers': ['file_request'], 'level': 'DEBUG' if DEBUG else 'WARNING', 'propagate': False, }, 'django.db.backends': { 'handlers': ['null'], 'propagate': False }, 'django.utils.autoreload': { 'level': 'INFO', }, }, } WEBPACK_LOADER = { 'DEFAULT': { 'CACHE': False, 'BUNDLE_DIR_NAME': './', 'STATS_FILE': os.path.join(STATIC_DIR, 'webpack-stats.json'), 'POLL_INTERVAL': 0.1, 'TIMEOUT': None, 'IGNORE': ['.+\\.hot-update.js', '.+\\.map'] } } LOGIN_URL = '/admin/login/' LOGIN_REDIRECT_URL = 'admin' SESSION_ENGINE = 'django.contrib.sessions.backends.cache' CACHES = { 'default': { 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache' }, 'db_cache': { 'BACKEND': 'django.core.cache.backends.db.DatabaseCache', 'LOCATION': 'swh_web_cache', } } JS_REVERSE_JS_MINIFY = False 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 index 214492b0..6dfa08bb 100644 --- a/swh/web/settings/tests.py +++ b/swh/web/settings/tests.py @@ -1,111 +1,115 @@ # Copyright (C) 2017-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 """ Django tests settings for swh-web. """ import os import sys from swh.web.config import get_config scope1_limiter_rate = 3 scope1_limiter_rate_post = 1 scope2_limiter_rate = 5 scope2_limiter_rate_post = 2 scope3_limiter_rate = 1 scope3_limiter_rate_post = 1 save_origin_rate_post = 10 swh_web_config = get_config() swh_web_config.update({ 'debug': False, 'secret_key': 'test', 'history_counters_url': '', 'throttling': { 'cache_uri': None, 'scopes': { 'swh_api': { 'limiter_rate': { 'default': '60/min' }, 'exempted_networks': ['127.0.0.0/8'] }, 'swh_api_origin_search': { 'limiter_rate': { 'default': '100/min' }, 'exempted_networks': ['127.0.0.0/8'] }, 'swh_api_origin_visit_latest': { 'limiter_rate': { 'default': '6000/min' }, 'exempted_networks': ['127.0.0.0/8'] }, 'swh_vault_cooking': { 'limiter_rate': { 'default': '120/h', 'GET': '60/m' }, 'exempted_networks': ['127.0.0.0/8'] }, 'swh_save_origin': { 'limiter_rate': { 'default': '120/h', 'POST': '%s/h' % save_origin_rate_post, } }, 'scope1': { 'limiter_rate': { 'default': '%s/min' % scope1_limiter_rate, 'POST': '%s/min' % scope1_limiter_rate_post, } }, 'scope2': { 'limiter_rate': { 'default': '%s/min' % scope2_limiter_rate, 'POST': '%s/min' % scope2_limiter_rate_post } }, 'scope3': { 'limiter_rate': { 'default': '%s/min' % scope3_limiter_rate, 'POST': '%s/min' % scope3_limiter_rate_post }, 'exempted_networks': ['127.0.0.0/8'] } } - } + }, + 'keycloak': { + 'server_url': 'http://localhost:8080/auth', + 'realm_name': 'SoftwareHeritage', + }, }) from .common import * # noqa from .common import ALLOWED_HOSTS, LOGGING # noqa DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': swh_web_config['test_db'], } } # when not running unit tests, make the webapp fetch data from memory storages if 'pytest' not in sys.argv[0] and 'PYTEST_XDIST_WORKER' not in os.environ: swh_web_config.update({ 'debug': True, 'e2e_tests_mode': True }) from swh.web.tests.data import get_tests_data, override_storages # noqa test_data = get_tests_data() override_storages(test_data['storage'], test_data['idx_storage'], test_data['search']) else: ALLOWED_HOSTS += ['testserver'] # Silent DEBUG output when running unit tests LOGGING['handlers']['console']['level'] = 'INFO' # type: ignore diff --git a/swh/web/templates/layout.html b/swh/web/templates/layout.html index 60dc74a1..5d5ad3ad 100644 --- a/swh/web/templates/layout.html +++ b/swh/web/templates/layout.html @@ -1,215 +1,231 @@ {% comment %} Copyright (C) 2015-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 {% endcomment %} {% load js_reverse %} {% load static %} {% load render_bundle from webpack_loader %} {% load swh_templatetags %} {% block title %}{% endblock %} {% render_bundle 'vendors' %} {% render_bundle 'webapp' %} {% block header %}{% endblock %}
{% block content %}{% endblock %}
{% include "includes/global-modals.html" %}
back to top
diff --git a/swh/web/templates/logout.html b/swh/web/templates/logout.html index a4052e12..989ab0a7 100644 --- a/swh/web/templates/logout.html +++ b/swh/web/templates/logout.html @@ -1,21 +1,27 @@ {% extends "layout.html" %} {% comment %} Copyright (C) 2018 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 {% endcomment %} {% load static %} {% block title %}Logged out – Software Heritage archive {% endblock %} {% block navbar-content %}

Logged out

{% endblock %} {% block content %}

You have been successfully logged out.

-

Log in again.

+

+{% if oidc_enabled and 'remote_user' in request.GET %} + +{% else %} + +{% endif %} +Log in again.

{% endblock %} diff --git a/swh/web/tests/auth/__init__.py b/swh/web/tests/auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/swh/web/tests/auth/keycloak_mock.py b/swh/web/tests/auth/keycloak_mock.py new file mode 100644 index 00000000..9252b398 --- /dev/null +++ b/swh/web/tests/auth/keycloak_mock.py @@ -0,0 +1,77 @@ +# 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 copy import copy +from unittest.mock import Mock + +from django.conf import settings +from django.utils import timezone + +from swh.web.auth.keycloak import KeycloakOpenIDConnect +from swh.web.config import get_config + +from .sample_data import oidc_profile, realm_public_key, userinfo + + +class KeycloackOpenIDConnectMock(KeycloakOpenIDConnect): + + def __init__(self, auth_success=True): + swhweb_config = get_config() + super().__init__(swhweb_config['keycloak']['server_url'], + swhweb_config['keycloak']['realm_name'], + settings.OIDC_SWH_WEB_CLIENT_ID) + self._keycloak.public_key = lambda: realm_public_key + self._keycloak.well_know = lambda: { + 'issuer': f'{self.server_url}realms/{self.realm_name}', + 'authorization_endpoint': (f'{self.server_url}realms/' + f'{self.realm_name}/protocol/' + 'openid-connect/auth'), + 'token_endpoint': (f'{self.server_url}realms/{self.realm_name}/' + 'protocol/openid-connect/token'), + 'token_introspection_endpoint': (f'{self.server_url}realms/' + f'{self.realm_name}/protocol/' + 'openid-connect/token/' + 'introspect'), + 'userinfo_endpoint': (f'{self.server_url}realms/{self.realm_name}/' + 'protocol/openid-connect/userinfo'), + 'end_session_endpoint': (f'{self.server_url}realms/' + f'{self.realm_name}/protocol/' + 'openid-connect/logout'), + 'jwks_uri': (f'{self.server_url}realms/{self.realm_name}/' + 'protocol/openid-connect/certs'), + } + self.authorization_code = Mock() + self.userinfo = Mock() + self.logout = Mock() + if auth_success: + self.authorization_code.return_value = copy(oidc_profile) + self.userinfo.return_value = copy(userinfo) + else: + self.authorization_url = Mock() + exception = Exception('Authentication failed') + self.authorization_code.side_effect = exception + self.authorization_url.side_effect = exception + self.userinfo.side_effect = exception + self.logout.side_effect = exception + + def decode_token(self, token): + # skip signature expiration check as we use a static oidc_profile + # for the tests with expired tokens in it + options = {'verify_exp': False} + decoded = super().decode_token(token, options) + # tweak auth and exp time for tests + expire_in = decoded['exp'] - decoded['auth_time'] + decoded['auth_time'] = int(timezone.now().timestamp()) + decoded['exp'] = decoded['auth_time'] + expire_in + return decoded + + +def mock_keycloak(mocker, auth_success=True): + kc_oidc_mock = KeycloackOpenIDConnectMock(auth_success) + mock_get_oidc_client = mocker.patch( + 'swh.web.auth.views.get_oidc_client') + mock_get_oidc_client.return_value = kc_oidc_mock + mocker.patch('swh.web.auth.backends._oidc_client', kc_oidc_mock) + return kc_oidc_mock diff --git a/swh/web/tests/auth/sample_data.py b/swh/web/tests/auth/sample_data.py new file mode 100644 index 00000000..604bae28 --- /dev/null +++ b/swh/web/tests/auth/sample_data.py @@ -0,0 +1,95 @@ +# 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 + + +realm_public_key = ( + 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnqF4xvGjaI54P6WtJvyGayxP8A93u' + 'NcA3TH6jitwmyAalj8dN8/NzK9vrdlSA3Ibvp/XQujPSOP7a35YiYFscEJnogTXQpE/FhZrUY' + 'y21U6ezruVUv4z/ER1cYLb+q5ZI86nXSTNCAbH+lw7rQjlvcJ9KvgHEeA5ALXJ1r55zUmNvuy' + '5o6ke1G3fXbNSXwF4qlWAzo1o7Ms8qNrNyOG8FPx24dvm9xMH7/08IPvh9KUqlnP8h6olpxHr' + 'drX/q4E+Nzj8Tr8p7Z5CimInls40QuOTIhs6C2SwFHUgQgXl9hB9umiZJlwYEpDv0/LO2zYie' + 'Hl5Lv7Iig4FOIXIVCaDGQIDAQAB' +) + +oidc_profile = { + 'access_token': ('eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJPSnhV' + 'Q0p0TmJQT0NOUGFNNmc3ZU1zY2pqTXhoem9vNGxZaFhsa1c2TWhBIn0.' + 'eyJqdGkiOiIzMWZjNTBiNy1iYmU1LTRmNTEtOTFlZi04ZTNlZWM1MTMz' + 'MWUiLCJleHAiOjE1ODI3MjM3MDEsIm5iZiI6MCwiaWF0IjoxNTgyNzIz' + 'MTAxLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFs' + 'bXMvU29mdHdhcmVIZXJpdGFnZSIsImF1ZCI6WyJzd2gtd2ViIiwiYWNj' + 'b3VudCJdLCJzdWIiOiJmZWFjZDM0NC1iNDY4LTRhNjUtYTIzNi0xNGY2' + 'MWU2YjcyMDAiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJzd2gtd2ViIiwi' + 'YXV0aF90aW1lIjoxNTgyNzIzMTAwLCJzZXNzaW9uX3N0YXRlIjoiZDgy' + 'YjkwZDEtMGE5NC00ZTc0LWFkNjYtZGQ5NTM0MWM3YjZkIiwiYWNyIjoi' + 'MSIsImFsbG93ZWQtb3JpZ2lucyI6WyIqIl0sInJlYWxtX2FjY2VzcyI6' + 'eyJyb2xlcyI6WyJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0' + 'aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xl' + 'cyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtz' + 'Iiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJvcGVuaWQgZW1haWwg' + 'cHJvZmlsZSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwibmFtZSI6Ikpv' + 'aG4gRG9lIiwiZ3JvdXBzIjpbXSwicHJlZmVycmVkX3VzZXJuYW1lIjoi' + 'am9obmRvZSIsImdpdmVuX25hbWUiOiJKb2huIiwiZmFtaWx5X25hbWUi' + 'OiJEb2UiLCJlbWFpbCI6ImpvaG4uZG9lQGV4YW1wbGUuY29tIn0.neJ-' + 'Pmd87J6Gt0fzDqmXFeoy34Iqb5vNNEEgIKqtqg3moaVkbXrO_9R37DJB' + 'AgdFv0owVONK3GbqPOEICePgG6RFtri999DetNE-O5sB4fwmHPWcHPlO' + 'kcPLbVJqu6zWo-2AzlfAy5bCNvj_wzs2tjFjLeHcRgR1a1WY3uTp5EWc' + 'HITCWQZzZWFGZTZCTlGkpdyJTqxGBdSHRB4NlIVGpYSTBsBsxttFEetl' + 'rpcNd4-5AteFprIr9hn9VasIIF8WdFdtC2e8xGMJW5Q0M3G3Iu-LLNmE' + 'oTIDqtbJ7OrIcGBIwsc3seCV3eCG6kOYwz5w-f8DeOpwcDX58yYPmapJ' + '6A'), + 'expires_in': 600, + 'id_token': ('eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJPSnhVQ0p0' + 'TmJQT0NOUGFNNmc3ZU1zY2pqTXhoem9vNGxZaFhsa1c2TWhBIn0.eyJqdGki' + 'OiI0NDRlYzU1My1iYzhiLTQ2YjYtOTlmYS0zOTc3YTJhZDY1ZmEiLCJleHAi' + 'OjE1ODI3MjM3MDEsIm5iZiI6MCwiaWF0IjoxNTgyNzIzMTAxLCJpc3MiOiJo' + 'dHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvU29mdHdhcmVIZXJp' + 'dGFnZSIsImF1ZCI6InN3aC13ZWIiLCJzdWIiOiJmZWFjZDM0NC1iNDY4LTRh' + 'NjUtYTIzNi0xNGY2MWU2YjcyMDAiLCJ0eXAiOiJJRCIsImF6cCI6InN3aC13' + 'ZWIiLCJhdXRoX3RpbWUiOjE1ODI3MjMxMDAsInNlc3Npb25fc3RhdGUiOiJk' + 'ODJiOTBkMS0wYTk0LTRlNzQtYWQ2Ni1kZDk1MzQxYzdiNmQiLCJhY3IiOiIx' + 'IiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJuYW1lIjoiSm9obiBEb2UiLCJn' + 'cm91cHMiOltdLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJqb2huZG9lIiwiZ2l2' + 'ZW5fbmFtZSI6IkpvaG4iLCJmYW1pbHlfbmFtZSI6IkRvZSIsImVtYWlsIjoi' + 'am9obi5kb2VAZXhhbXBsZS5jb20ifQ.YB7bxlz_wgLJSkylVjmqedxQgEMee' + 'JOdi9CFHXV4F3ZWsEZ52CGuJXsozkX2oXvgU06MzzLNEK8ojgrPSNzjRkutL' + 'aaLq_YUzv4iV8fmKUS_aEyiYZbfoBe3Y4dwv2FoPEPCt96iTwpzM5fg_oYw_' + 'PHCq-Yl5SulT1nTrJZpntkf0hRjmxlDO06JMp0aZ8xS8RYJqH48xCRf_DARE' + '0jJV2-UuzOWI6xBATwFfP44kV6wFmErLN5txMgwZzCSB2OCe5Cl1il0eTQTN' + 'ybeSYZeZE61QtuTRUHeP1D1qSbJGy5g_S67SdTkS-hQFvfrrD84qGflIEqnX' + 'ZbYnitD1Typ6Q'), + 'not-before-policy': 0, + 'refresh_expires_in': 1800, + 'refresh_token': ('eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJmNjM' + 'zMDE5MS01YTU4LTQxMDAtOGIzYS00ZDdlM2U1NjA3MTgifQ.eyJqdGk' + 'iOiIxYWI5ZWZmMS0xZWZlLTQ3MDMtOGQ2YS03Nzg1NWUwYzQyYTYiLC' + 'JleHAiOjE1ODI3MjQ5MDEsIm5iZiI6MCwiaWF0IjoxNTgyNzIzMTAxL' + 'CJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMv' + 'U29mdHdhcmVIZXJpdGFnZSIsImF1ZCI6Imh0dHA6Ly9sb2NhbGhvc3Q' + '6ODA4MC9hdXRoL3JlYWxtcy9Tb2Z0d2FyZUhlcml0YWdlIiwic3ViIj' + 'oiZmVhY2QzNDQtYjQ2OC00YTY1LWEyMzYtMTRmNjFlNmI3MjAwIiwid' + 'HlwIjoiUmVmcmVzaCIsImF6cCI6InN3aC13ZWIiLCJhdXRoX3RpbWUi' + 'OjAsInNlc3Npb25fc3RhdGUiOiJkODJiOTBkMS0wYTk0LTRlNzQtYWQ' + '2Ni1kZDk1MzQxYzdiNmQiLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOl' + 'sib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiJdfSwic' + 'mVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFu' + 'YWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXc' + 'tcHJvZmlsZSJdfX0sInNjb3BlIjoib3BlbmlkIGVtYWlsIHByb2ZpbG' + 'UifQ.xQYrl2CMP_GQ_TFqhsTz-rTs3WuZz5I37toi1eSsDMI'), + 'scope': 'openid email profile', + 'session_state': 'd82b90d1-0a94-4e74-ad66-dd95341c7b6d', + 'token_type': 'bearer' +} + +userinfo = { + 'email': 'john.doe@example.com', + 'email_verified': False, + 'family_name': 'Doe', + 'given_name': 'John', + 'groups': ['/staff'], + 'name': 'John Doe', + 'preferred_username': 'johndoe', + 'sub': 'feacd344-b468-4a65-a236-14f61e6b7200' +} diff --git a/swh/web/tests/auth/test_backends.py b/swh/web/tests/auth/test_backends.py new file mode 100644 index 00000000..88bc7c11 --- /dev/null +++ b/swh/web/tests/auth/test_backends.py @@ -0,0 +1,81 @@ +# 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 django.contrib.auth import authenticate, get_backends + +import pytest + +from django.conf import settings + +from swh.web.auth.models import OIDCUser +from swh.web.common.utils import reverse + +from . import sample_data +from .keycloak_mock import mock_keycloak + + +def _authenticate_user(request_factory): + request = request_factory.get(reverse('oidc-login-complete')) + + return authenticate(request=request, + code='some-code', + code_verifier='some-code-verifier', + redirect_uri='https://localhost:5004') + + +def _check_authenticated_user(user): + userinfo = sample_data.userinfo + assert user is not None + assert isinstance(user, OIDCUser) + assert user.id != 0 + assert user.username == userinfo['preferred_username'] + assert user.password == '' + assert user.first_name == userinfo['given_name'] + assert user.last_name == userinfo['family_name'] + assert user.email == userinfo['email'] + assert user.is_staff == ('/staff' in userinfo['groups']) + assert user.sub == userinfo['sub'] + + +@pytest.mark.django_db +def test_oidc_code_pkce_auth_backend_success(mocker, request_factory): + kc_oidc_mock = mock_keycloak(mocker) + oidc_profile = sample_data.oidc_profile + user = _authenticate_user(request_factory) + + _check_authenticated_user(user) + + decoded_token = kc_oidc_mock.decode_token( + sample_data.oidc_profile['access_token']) + auth_datetime = datetime.fromtimestamp(decoded_token['auth_time']) + + access_expiration = ( + auth_datetime + timedelta(seconds=oidc_profile['expires_in'])) + refresh_expiration = ( + auth_datetime + timedelta(seconds=oidc_profile['refresh_expires_in'])) + + assert user.access_token == oidc_profile['access_token'] + assert user.access_expiration == access_expiration + assert user.id_token == oidc_profile['id_token'] + assert user.refresh_token == oidc_profile['refresh_token'] + assert user.refresh_expiration == refresh_expiration + assert user.scope == oidc_profile['scope'] + assert user.session_state == oidc_profile['session_state'] + + backend_path = 'swh.web.auth.backends.OIDCAuthorizationCodePKCEBackend' + assert user.backend == backend_path + backend_idx = settings.AUTHENTICATION_BACKENDS.index(backend_path) + assert get_backends()[backend_idx].get_user(user.id) == user + + +@pytest.mark.django_db +def test_oidc_code_pkce_auth_backend_failure(mocker, request_factory): + mock_keycloak(mocker, auth_success=False) + + user = _authenticate_user(request_factory) + + assert user is None diff --git a/swh/web/tests/auth/test_utils.py b/swh/web/tests/auth/test_utils.py new file mode 100644 index 00000000..d564b199 --- /dev/null +++ b/swh/web/tests/auth/test_utils.py @@ -0,0 +1,37 @@ +# 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 re + +from base64 import urlsafe_b64encode + +from swh.web.auth.utils import gen_oidc_pkce_codes + + +def test_gen_oidc_pkce_codes(): + """ + Check generated PKCE codes respect the specification + (see https://tools.ietf.org/html/rfc7636#section-4.1) + """ + code_verifier, code_challenge = gen_oidc_pkce_codes() + + # check the code verifier only contains allowed characters + assert re.match(r'[a-zA-Z0-9-\._~]+', code_verifier) + + # check minimum and maximum authorized length for the + # code verifier + assert len(code_verifier) >= 43 + assert len(code_verifier) <= 128 + + # compute code challenge from code verifier + challenge = hashlib.sha256(code_verifier.encode('ascii')).digest() + challenge = urlsafe_b64encode(challenge).decode('ascii') + challenge = challenge.replace('=', '') + + # check base64 padding is not present + assert not code_challenge[-1].endswith('=') + # check code challenge is valid + assert code_challenge == challenge diff --git a/swh/web/tests/auth/test_views.py b/swh/web/tests/auth/test_views.py new file mode 100644 index 00000000..8b32650b --- /dev/null +++ b/swh/web/tests/auth/test_views.py @@ -0,0 +1,275 @@ +# 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 urllib.parse import urljoin, urlparse +import uuid + +from django.conf import settings +from django.http import QueryDict +from django.contrib.auth.models import AnonymousUser, User + +import pytest + +from swh.web.auth.models import OIDCUser +from swh.web.common.utils import reverse +from swh.web.tests.django_asserts import assert_template_used, assert_contains + +from . import sample_data +from .keycloak_mock import mock_keycloak + + +@pytest.mark.django_db +def test_oidc_login_views_success(client, mocker): + """ + Simulate a successful login authentication with OpenID Connect + authorization code flow with PKCE. + """ + # mock Keycloak client + kc_oidc_mock = mock_keycloak(mocker) + + # user initiates login process + login_url = reverse('oidc-login') + response = client.get(login_url) + request = response.wsgi_request + + # should redirect to Keycloak authentication page in order + # for a user to login with its username / password + assert response.status_code == 302 + assert isinstance(request.user, AnonymousUser) + + parsed_url = urlparse(response['location']) + + authorization_url = kc_oidc_mock.well_known()['authorization_endpoint'] + query_dict = QueryDict(parsed_url.query) + + # check redirect url is valid + assert urljoin(response['location'], parsed_url.path) == authorization_url + assert 'client_id' in query_dict + assert query_dict['client_id'] == settings.OIDC_SWH_WEB_CLIENT_ID + assert 'response_type' in query_dict + assert query_dict['response_type'] == 'code' + assert 'redirect_uri' in query_dict + assert query_dict['redirect_uri'] == reverse('oidc-login-complete', + request=request) + assert 'code_challenge_method' in query_dict + assert query_dict['code_challenge_method'] == 'S256' + assert 'scope' in query_dict + assert query_dict['scope'] == 'openid' + assert 'state' in query_dict + assert 'code_challenge' in query_dict + + # check a login_data has been registered in user session + assert 'login_data' in request.session + login_data = request.session['login_data'] + assert 'code_verifier' in login_data + assert 'state' in login_data + assert 'redirect_uri' in login_data + assert login_data['redirect_uri'] == query_dict['redirect_uri'] + + # once a user has identified himself in Keycloak, he is + # redirected to the 'oidc-login-complete' view to + # login in Django. + + # generate authorization code / session state in the same + # manner as Keycloak + code = f'{str(uuid.uuid4())}.{str(uuid.uuid4())}.{str(uuid.uuid4())}' + session_state = str(uuid.uuid4()) + + login_complete_url = reverse('oidc-login-complete', + query_params={'code': code, + 'state': login_data['state'], + 'session_state': session_state}) + + # login process finalization + response = client.get(login_complete_url) + request = response.wsgi_request + + # should redirect to root url by default + assert response.status_code == 302 + assert response['location'] == request.build_absolute_uri('/') + + # user should be authenticated + assert isinstance(request.user, OIDCUser) + + # check remote user has not been saved to Django database + with pytest.raises(User.DoesNotExist): + User.objects.get(username=request.user.username) + + +@pytest.mark.django_db +def test_oidc_logout_view_success(client, mocker): + """ + Simulate a successful logout operation with OpenID Connect. + """ + # mock Keycloak client + kc_oidc_mock = mock_keycloak(mocker) + # login our test user + client.login(code='', code_verifier='', redirect_uri='') + kc_oidc_mock.authorization_code.assert_called() + + # user initiates logout + oidc_logout_url = reverse('oidc-logout') + response = client.get(oidc_logout_url) + request = response.wsgi_request + + # should redirect to logout page + assert response.status_code == 302 + logout_url = reverse('logout', query_params={'remote_user': 1}) + assert response['location'] == request.build_absolute_uri(logout_url) + + # should have been logged out in Keycloak + kc_oidc_mock.logout.assert_called_with( + sample_data.oidc_profile['refresh_token']) + + # check effective logout in Django + assert isinstance(request.user, AnonymousUser) + + +@pytest.mark.django_db +def test_oidc_login_view_failure(client, mocker): + """ + Simulate a failed authentication with OpenID Connect. + """ + # mock Keycloak client + mock_keycloak(mocker, auth_success=False) + + # user initiates login process + login_url = reverse('oidc-login') + response = client.get(login_url) + request = response.wsgi_request + + # should render an error page + assert response.status_code == 500 + assert_template_used(response, "error.html") + + # no users should be logged in + assert isinstance(request.user, AnonymousUser) + + +# Simulate possible errors with OpenID Connect in the login complete view. + +def test_oidc_login_complete_view_no_login_data(client, mocker): + # user initiates login process + login_url = reverse('oidc-login-complete') + response = client.get(login_url) + + # should render an error page + assert_template_used(response, "error.html") + assert_contains(response, 'Login process has not been initialized.', + status_code=500) + + +def test_oidc_login_complete_view_missing_parameters(client, mocker): + # simulate login process has been initialized + session = client.session + session['login_data'] = { + 'code_verifier': '', + 'state': str(uuid.uuid4()), + 'redirect_uri': '', + 'next': None, + } + session.save() + + # user initiates login process + login_url = reverse('oidc-login-complete') + response = client.get(login_url) + request = response.wsgi_request + + # should render an error page + assert_template_used(response, "error.html") + assert_contains(response, 'Missing query parameters for authentication.', + status_code=400) + + # no user should be logged in + assert isinstance(request.user, AnonymousUser) + + +def test_oidc_login_complete_wrong_csrf_token(client, mocker): + # mock Keycloak client + mock_keycloak(mocker) + + # simulate login process has been initialized + session = client.session + session['login_data'] = { + 'code_verifier': '', + 'state': str(uuid.uuid4()), + 'redirect_uri': '', + 'next': None, + } + session.save() + + # user initiates login process + login_url = reverse('oidc-login-complete', + query_params={'code': 'some-code', + 'state': 'some-state'}) + + response = client.get(login_url) + request = response.wsgi_request + + # should render an error page + assert_template_used(response, "error.html") + assert_contains(response, 'Wrong CSRF token, aborting login process.', + status_code=400) + + # no user should be logged in + assert isinstance(request.user, AnonymousUser) + + +@pytest.mark.django_db +def test_oidc_login_complete_wrong_code_verifier(client, mocker): + # mock Keycloak client + mock_keycloak(mocker, auth_success=False) + + # simulate login process has been initialized + session = client.session + session['login_data'] = { + 'code_verifier': '', + 'state': str(uuid.uuid4()), + 'redirect_uri': '', + 'next': None, + } + session.save() + + # check authentication error is reported + login_url = reverse('oidc-login-complete', + query_params={'code': 'some-code', + 'state': session['login_data']['state']}) + + response = client.get(login_url) + request = response.wsgi_request + + # should render an error page + assert_template_used(response, "error.html") + assert_contains(response, 'User authentication failed.', + status_code=500) + + # no user should be logged in + assert isinstance(request.user, AnonymousUser) + + +@pytest.mark.django_db +def test_oidc_logout_view_failure(client, mocker): + """ + Simulate a failed logout operation with OpenID Connect. + """ + # mock Keycloak client + kc_oidc_mock = mock_keycloak(mocker) + # login our test user + client.login(code='', code_verifier='', redirect_uri='') + + err_msg = 'Authentication server error' + kc_oidc_mock.logout.side_effect = Exception(err_msg) + + # user initiates logout process + logout_url = reverse('oidc-logout') + response = client.get(logout_url) + request = response.wsgi_request + + # should render an error page + assert_template_used(response, "error.html") + assert_contains(response, err_msg, status_code=500) + + # user should be logged out from Django anyway + assert isinstance(request.user, AnonymousUser) diff --git a/swh/web/urls.py b/swh/web/urls.py index f6ecfb7f..c4e5be4e 100644 --- a/swh/web/urls.py +++ b/swh/web/urls.py @@ -1,61 +1,67 @@ -# Copyright (C) 2017-2019 The Software Heritage developers +# Copyright (C) 2017-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 django.conf import settings from django.conf.urls import ( url, include, handler400, handler403, handler404, handler500 ) +from django.contrib.auth.views import LogoutView from django.contrib.staticfiles.views import serve + from django.shortcuts import render from django.views.generic.base import RedirectView from django_js_reverse.views import urls_js from swh.web.browse.identifiers import swh_id_browse from swh.web.config import get_config from swh.web.common.exc import ( swh_handle400, swh_handle403, swh_handle404, swh_handle500 ) swh_web_config = get_config() favicon_view = RedirectView.as_view(url='/static/img/icons/swh-logo-32x32.png', permanent=True) def _default_view(request): return render(request, "homepage.html") urlpatterns = [ url(r'^admin/', include('swh.web.admin.urls')), url(r'^favicon\.ico$', favicon_view), url(r'^api/', include('swh.web.api.urls')), url(r'^browse/', include('swh.web.browse.urls')), url(r'^$', _default_view, name='swh-web-homepage'), url(r'^jsreverse/$', urls_js, name='js_reverse'), url(r'^(?Pswh:[0-9]+:[a-z]+:[0-9a-f]+.*)/$', swh_id_browse, name='browse-swh-id'), url(r'^', include('swh.web.misc.urls')), + url(r'^', include('swh.web.auth.views')), + url(r'^logout/$', + LogoutView.as_view(template_name='logout.html'), + name='logout'), ] # allow to serve assets through django staticfiles # even if settings.DEBUG is False def insecure_serve(request, path, **kwargs): return serve(request, path, insecure=True, **kwargs) # enable to serve compressed assets through django development server if swh_web_config['serve_assets']: static_pattern = r'^%s(?P.*)$' % settings.STATIC_URL[1:] urlpatterns.append(url(static_pattern, insecure_serve)) handler400 = swh_handle400 # noqa handler403 = swh_handle403 # noqa handler404 = swh_handle404 # noqa handler500 = swh_handle500 # noqa