Page Menu
Home
Software Heritage
Search
Configure Global Search
Log In
Files
F7066154
D2877.id10424.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
12 KB
Subscribers
None
D2877.id10424.diff
View Options
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
Details
Attached
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
Attached To
D2877: auth: Add OIDC session silent refresh middleware
Event Timeline
Log In to Comment