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 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,105 @@ +# 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 Dict, Optional + +from django.http import HttpRequest + +from swh.web.auth.utils import get_oidc_client +from swh.web.auth.models import OIDCUser + +_auth_exception: Dict[str, Exception] = {} + + +def _oidc_user_from_info(userinfo): + # create a Django user that will not be saved to database + user = OIDCUser(id=userinfo['user_id'], + username=userinfo['preferred_username'], + password='', + first_name=userinfo['given_name'], + last_name=userinfo['family_name'], + email=userinfo['email']) + + # add OIDC userinfo to custom User proxy model + user.userinfo = userinfo + + return user + + +def _create_oidc_user(oidc_client, access_token): + # get OIDC userinfo + userinfo = oidc_client.userinfo(access_token) + + # 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) + userinfo['user_id'] = user_id + + return _oidc_user_from_info(userinfo) + + +class OIDCAuthorizationCodePKCEBackend: + + _users: Dict[int, OIDCUser] = {} + + def authenticate(self, request: HttpRequest, code: str, code_verifier: str, + redirect_uri: str) -> Optional[OIDCUser]: + + user = None + try: + # get OpenID Connect client to communicate with Keycloak server + oidc_client = get_oidc_client() + + # try to authenticate user with OIDC PKCE authorization code flow + oidc_profile = oidc_client.authorization_code( + code, redirect_uri, code_verifier=code_verifier) + access_token = oidc_profile['access_token'] + + # create Django user + user = _create_oidc_user( + oidc_client, oidc_profile['access_token']) + + # 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['expire_datetime'] = ( + auth_datetime + + timedelta(seconds=oidc_profile['expires_in'])) + oidc_profile['refresh_expire_datetime'] = ( + auth_datetime + + timedelta(seconds=oidc_profile['refresh_expires_in'])) + + # add OIDC profile to custom User proxy model + user.oidc_profile = oidc_profile + + # register authenticated user + self._users[user.id] = user + except Exception as e: + global _auth_exception + _auth_exception[code_verifier] = e + + return user + + def get_user(self, user_id: int) -> Optional[OIDCUser]: + if user_id in self._users: + return self._users[user_id] + else: + return None + + +def get_auth_exception(code_verifier: str) -> Exception: + """ + Returns the caught exception when user authentication failed. + + Args: + code_verifier: Code verifier used for authentication + """ + return _auth_exception[code_verifier] 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,160 @@ +# 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 json + +from typing import Any, Dict, Optional +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: + certs = self._keycloak.certs() + self.realm_public_key = json.dumps(certs['keys'][0]) + + 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) + + +_keycloak_oidc: Dict[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_id = f'{realm_name}_{client_id}' + if realm_client_id not in _keycloak_oidc: + _keycloak_oidc[realm_client_id] = KeycloakOpenIDConnect(server_url, + realm_name, + client_id) + return _keycloak_oidc[realm_client_id] 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,56 @@ +# 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 + +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_profile: Dict[str, Any] = {} + _userinfo: Dict[str, Any] = {} + + class Meta: + app_label = 'swh.web.auth' + proxy = True + + def get_oidc_profile(self) -> Dict[str, Any]: + return self._oidc_profile + + def set_oidc_profile(self, oidc_profile: Dict[str, Any]): + self._oidc_profile = oidc_profile + + def get_userinfo(self) -> Dict[str, Any]: + return self._userinfo + + def set_userinfo(self, userinfo: Dict[str, Any]): + self._userinfo = userinfo + + oidc_profile = property(get_oidc_profile, set_oidc_profile) + userinfo = property(get_userinfo, set_userinfo) + + @property + def pk(self) -> int: + """ + Enable to check if a user is a remote one in Django templates + without raising a django.template.base.VariableDoesNotExist + exception. + """ + return 0 + + 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,65 @@ +# 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 os +import re + +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 taken 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 = urlsafe_b64encode(os.urandom(60)) + code_verifier_str = code_verifier.decode('utf-8') + code_verifier_str = re.sub(r'[^a-zA-Z0-9-\._~]+', '', code_verifier_str) + + # 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('utf-8')).digest() + code_challenge_str = urlsafe_b64encode(code_challenge).decode('utf-8') + 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,115 @@ +# 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.contrib.auth import authenticate, login, logout +from django.http import HttpRequest +from django.http.response import HttpResponse, HttpResponseRedirect + +from swh.web.auth.backends import get_auth_exception +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. + """ + state = str(uuid.uuid4()) + redirect_uri = reverse('oidc-login-complete', request=request) + + code_verifier, code_challenge = gen_oidc_pkce_codes() + + request.session['nonce'] = { + 'code_verifier': code_verifier, + 'state': state, + 'redirect_uri': redirect_uri, + 'next_path': request.GET.get('next'), + } + + 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 'nonce' 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.') + + state = request.GET['state'] + + nonce = request.session['nonce'] + + if state != nonce['state']: + raise BadInputExc('CSRF attack detected, aborting login process.') + + user = authenticate(request=request, + code=request.GET['code'], + code_verifier=nonce['code_verifier'], + redirect_uri=nonce['redirect_uri']) + + if user is None: + raise get_auth_exception(nonce['code_verifier']) + + login(request, user) + + del request.session['nonce'] + + redirect_url = nonce['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, 'oidc_profile'): + oidc_client = get_oidc_client() + user = cast(OIDCUser, user) + oidc_client.logout(user.oidc_profile['refresh_token']) + + 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/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': 'http://localhost:8080/auth/', + 'realm_name': 'SoftwareHeritage' + }), } 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 @@ -45,7 +45,7 @@ 'swh.web.browse', 'webpack_loader', 'django_js_reverse', - 'corsheaders' + 'corsheaders', ] MIDDLEWARE = [ @@ -57,7 +57,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) @@ -289,3 +289,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/templates/layout.html b/swh/web/templates/layout.html --- a/swh/web/templates/layout.html +++ b/swh/web/templates/layout.html @@ -71,6 +71,9 @@
@@ -160,7 +172,7 @@

Help

- {% if user.is_authenticated %} + {% if user.is_authenticated and user.is_staff %}