Page MenuHomeSoftware Heritage

D2877.id10424.diff
No OneTemporary

D2877.id10424.diff

diff --git a/swh/web/auth/middlewares.py b/swh/web/auth/middlewares.py
new file mode 100644
--- /dev/null
+++ b/swh/web/auth/middlewares.py
@@ -0,0 +1,42 @@
+# 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 django.contrib.auth import BACKEND_SESSION_KEY
+from django.http.response import HttpResponseRedirect
+
+from swh.web.common.utils import reverse
+
+
+class OIDCSessionRefreshMiddleware:
+ """
+ Middleware for silently refreshing on OpenID Connect session from
+ the browser and get new access token.
+ """
+ def __init__(self, get_response=None):
+ self.get_response = get_response
+ self.exempted_urls = [
+ reverse(v) for v in ('logout',
+ 'oidc-login',
+ 'oidc-login-complete',
+ 'oidc-logout')
+ ]
+
+ def __call__(self, request):
+ if (request.method != 'GET'
+ or request.user.is_authenticated
+ or BACKEND_SESSION_KEY not in request.session
+ or 'OIDC' not in request.session[BACKEND_SESSION_KEY]
+ or request.path in self.exempted_urls):
+ return self.get_response(request)
+
+ # At that point, we know that a OIDC user was previously logged in.
+ # Access token has expired so we attempt a silent refresh.
+ # If the latter failed because the session expired too, user will be
+ # redirected to logout page and a link will be offered to login again.
+ next_path = request.get_full_path()
+ redirect_url = reverse('oidc-login',
+ query_params={'next_path': next_path,
+ 'prompt': 'none'})
+ return HttpResponseRedirect(redirect_url)
diff --git a/swh/web/auth/views.py b/swh/web/auth/views.py
--- a/swh/web/auth/views.py
+++ b/swh/web/auth/views.py
@@ -11,7 +11,9 @@
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 django.http.response import (
+ HttpResponse, HttpResponseRedirect, HttpResponseServerError
+)
from swh.web.auth.models import OIDCUser
from swh.web.auth.utils import gen_oidc_pkce_codes, get_oidc_client
@@ -33,7 +35,8 @@
'code_verifier': code_verifier,
'state': state,
'redirect_uri': redirect_uri,
- 'next_path': request.GET.get('next_path'),
+ 'next_path': request.GET.get('next_path', ''),
+ 'prompt': request.GET.get('prompt', ''),
}
authorization_url_params = {
@@ -41,6 +44,7 @@
'code_challenge': code_challenge,
'code_challenge_method': 'S256',
'scope': 'openid',
+ 'prompt': request.GET.get('prompt', ''),
}
try:
@@ -61,14 +65,26 @@
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:
+ login_data = request.session['login_data']
+ next_path = login_data['next_path'] or request.build_absolute_uri('/')
+
+ if 'error' in request.GET:
+ if login_data['prompt'] == 'none':
+ # Silent login failed because OIDC session expired.
+ # Redirect to logout page and inform user.
+ logout(request)
+ logout_url = reverse('logout',
+ query_params={'next_path': next_path,
+ 'remote_user': 1})
+ return HttpResponseRedirect(logout_url)
+ return HttpResponseServerError(request.GET['error'])
+
+ if 'code' not in request.GET or '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.')
@@ -82,10 +98,7 @@
login(request, user)
- redirect_url = (login_data['next_path'] or
- request.build_absolute_uri('/'))
-
- return HttpResponseRedirect(redirect_url)
+ return HttpResponseRedirect(next_path)
except Exception as e:
return handle_view_exception(request, e)
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
@@ -57,6 +57,7 @@
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
+ 'swh.web.auth.middlewares.OIDCSessionRefreshMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'swh.web.common.middlewares.ThrottlingHeadersMiddleware',
diff --git a/swh/web/templates/logout.html b/swh/web/templates/logout.html
--- a/swh/web/templates/logout.html
+++ b/swh/web/templates/logout.html
@@ -16,12 +16,27 @@
{% endblock %}
{% block content %}
+{% if 'next_path' in request.GET %}
+<p>Your authenticated session expired so you have been automatically logged out.</p>
+{% else %}
<p>You have been successfully logged out.</p>
+{% endif %}
<p>
{% if oidc_enabled and 'remote_user' in request.GET %}
+{% if 'next_path' in request.GET %}
+<a href="{% url 'oidc-login' %}?next_path={{ request.GET.next_path }}">
+{% else %}
<a href="{% url 'oidc-login' %}">
+{% endif %}
{% else %}
<a href="{% url 'login' %}">
{% endif %}
-Log in</a> again.</p>
+Log in</a> again ?</p>
+{% if 'next_path' in request.GET %}
+<p>
+Or go back to
+<a href="{{ request.GET.next_path }}">
+previous page</a>.
+</p>
+{% endif %}
{% endblock %}
diff --git a/swh/web/tests/api/test_throttling.py b/swh/web/tests/api/test_throttling.py
--- a/swh/web/tests/api/test_throttling.py
+++ b/swh/web/tests/api/test_throttling.py
@@ -19,6 +19,7 @@
scope2_limiter_rate, scope2_limiter_rate_post,
scope3_limiter_rate, scope3_limiter_rate_post
)
+from swh.web.urls import urlpatterns
class MockViewScope1(APIView):
@@ -61,7 +62,7 @@
return Response('bar_post')
-urlpatterns = [
+urlpatterns += [
url(r'^scope1_class$', MockViewScope1.as_view()),
url(r'^scope2_func$', mock_view_scope2),
url(r'^scope3_class$', MockViewScope3.as_view()),
diff --git a/swh/web/tests/auth/keycloak_mock.py b/swh/web/tests/auth/keycloak_mock.py
--- a/swh/web/tests/auth/keycloak_mock.py
+++ b/swh/web/tests/auth/keycloak_mock.py
@@ -17,12 +17,13 @@
class KeycloackOpenIDConnectMock(KeycloakOpenIDConnect):
- def __init__(self, auth_success=True):
+ def __init__(self, auth_success=True, exp=None):
swhweb_config = get_config()
super().__init__(swhweb_config['keycloak']['server_url'],
swhweb_config['keycloak']['realm_name'],
OIDC_SWH_WEB_CLIENT_ID)
self.auth_success = auth_success
+ self.exp = exp
self._keycloak.public_key = lambda: realm_public_key
self._keycloak.well_know = lambda: {
'issuer': f'{self.server_url}realms/{self.realm_name}',
@@ -66,14 +67,18 @@
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
+ if self.exp is not None:
+ decoded['exp'] = self.exp
+ decoded['auth_time'] = self.exp - expire_in
+ else:
+ decoded['auth_time'] = int(timezone.now().timestamp())
+ decoded['exp'] = decoded['auth_time'] + expire_in
decoded['groups'] = ['/staff']
return decoded
-def mock_keycloak(mocker, auth_success=True):
- kc_oidc_mock = KeycloackOpenIDConnectMock(auth_success)
+def mock_keycloak(mocker, auth_success=True, exp=None):
+ kc_oidc_mock = KeycloackOpenIDConnectMock(auth_success, exp)
mock_get_oidc_client = mocker.patch(
'swh.web.auth.views.get_oidc_client')
mock_get_oidc_client.return_value = kc_oidc_mock
diff --git a/swh/web/tests/auth/test_middlewares.py b/swh/web/tests/auth/test_middlewares.py
new file mode 100644
--- /dev/null
+++ b/swh/web/tests/auth/test_middlewares.py
@@ -0,0 +1,46 @@
+# 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 django.test import modify_settings
+import pytest
+
+from .keycloak_mock import mock_keycloak
+
+from swh.web.common.utils import reverse
+
+
+@pytest.mark.django_db
+@modify_settings(MIDDLEWARE={
+ 'remove': ['swh.web.auth.middlewares.OIDCSessionRefreshMiddleware']
+})
+def test_oidc_session_refresh_middleware_disabled(client, mocker):
+ # authenticate but make session expires immediately
+ kc_oidc_mock = mock_keycloak(mocker, exp=int(datetime.now().timestamp()))
+ client.login(code='', code_verifier='', redirect_uri='')
+ kc_oidc_mock.authorization_code.assert_called()
+
+ url = reverse('swh-web-homepage')
+ resp = client.get(url)
+ # no redirection for silent refresh
+ assert resp.status_code != 302
+
+
+@pytest.mark.django_db
+def test_oidc_session_refresh_middleware_enabled(client, mocker):
+ # authenticate but make session expires immediately
+ kc_oidc_mock = mock_keycloak(mocker, exp=int(datetime.now().timestamp()))
+ client.login(code='', code_verifier='', redirect_uri='')
+ kc_oidc_mock.authorization_code.assert_called()
+
+ url = reverse('swh-web-homepage')
+ resp = client.get(url)
+
+ # should redirect for silent refresh
+ assert resp.status_code == 302
+ slient_refresh_url = reverse('oidc-login', query_params={'next_path': url,
+ 'prompt': 'none'})
+ assert resp['location'] == slient_refresh_url
diff --git a/swh/web/tests/auth/test_views.py b/swh/web/tests/auth/test_views.py
--- a/swh/web/tests/auth/test_views.py
+++ b/swh/web/tests/auth/test_views.py
@@ -168,7 +168,8 @@
'code_verifier': '',
'state': str(uuid.uuid4()),
'redirect_uri': '',
- 'next': None,
+ 'next_path': '',
+ 'prompt': '',
}
session.save()
@@ -196,7 +197,8 @@
'code_verifier': '',
'state': str(uuid.uuid4()),
'redirect_uri': '',
- 'next': None,
+ 'next_path': '',
+ 'prompt': '',
}
session.save()
@@ -228,7 +230,8 @@
'code_verifier': '',
'state': str(uuid.uuid4()),
'redirect_uri': '',
- 'next': None,
+ 'next_path': '',
+ 'prompt': '',
}
session.save()
@@ -273,3 +276,41 @@
# user should be logged out from Django anyway
assert isinstance(request.user, AnonymousUser)
+
+
+@pytest.mark.django_db
+def test_oidc_silent_refresh_failure(client, mocker):
+ # mock Keycloak client
+ mock_keycloak(mocker)
+
+ next_path = reverse('swh-web-homepage')
+
+ # silent session refresh initialization
+ login_url = reverse('oidc-login', query_params={'next_path': next_path,
+ 'prompt': 'none'})
+ response = client.get(login_url)
+ request = response.wsgi_request
+
+ login_data = request.session['login_data']
+
+ # check prompt value has been registered in user session
+ assert 'prompt' in login_data
+ assert login_data['prompt'] == 'none'
+
+ # simulate a failed silent session refresh
+ session_state = str(uuid.uuid4())
+
+ login_complete_url = reverse('oidc-login-complete',
+ query_params={'error': 'login_required',
+ 'state': login_data['state'],
+ 'session_state': session_state})
+
+ # login process finalization
+ response = client.get(login_complete_url)
+ request = response.wsgi_request
+
+ # should redirect to logout page
+ assert response.status_code == 302
+ logout_url = reverse('logout', query_params={'next_path': next_path,
+ 'remote_user': 1})
+ assert response['location'] == logout_url

File Metadata

Mime Type
text/plain
Expires
Nov 4 2024, 7:59 PM (9 w, 2 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3226336

Event Timeline