diff --git a/swh/web/auth/utils.py b/swh/web/auth/utils.py index 4c561b35..bda492d3 100644 --- a/swh/web/auth/utils.py +++ b/swh/web/auth/utils.py @@ -1,62 +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: +OIDC_SWH_WEB_CLIENT_ID = 'swh-web' + + +def get_oidc_client(client_id: str = OIDC_SWH_WEB_CLIENT_ID + ) -> 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/settings/common.py b/swh/web/settings/common.py index 0fc3f08e..87df8f70 100644 --- a/swh/web/settings/common.py +++ b/swh/web/settings/common.py @@ -1,304 +1,302 @@ # 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', ] 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', ] # 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.api.throttling.SwhWebRateThrottle', ), 'DEFAULT_THROTTLE_RATES': throttle_rates, 'DEFAULT_AUTHENTICATION_CLASSES': [ 'rest_framework.authentication.SessionAuthentication', 'swh.web.auth.backends.OIDCBearerTokenAuthentication', ], } 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/tests/auth/keycloak_mock.py b/swh/web/tests/auth/keycloak_mock.py index 9252b398..e6008648 100644 --- a/swh/web/tests/auth/keycloak_mock.py +++ b/swh/web/tests/auth/keycloak_mock.py @@ -1,77 +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.auth.utils import OIDC_SWH_WEB_CLIENT_ID 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) + 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/test_views.py b/swh/web/tests/auth/test_views.py index 8b32650b..19bd7360 100644 --- a/swh/web/tests/auth/test_views.py +++ b/swh/web/tests/auth/test_views.py @@ -1,275 +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.auth.utils import OIDC_SWH_WEB_CLIENT_ID 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 query_dict['client_id'] == 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)