Skip to content

Commit a8aba23

Browse files
regilerogbip
authored andcommitted
Better cache implem, add sso metada in shared cache.
1 parent 95e4333 commit a8aba23

File tree

5 files changed

+149
-117
lines changed

5 files changed

+149
-117
lines changed

django_pyoidc/utils.py

+46
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1+
import hashlib
2+
import logging
3+
from typing import Dict, Union
4+
15
from django.conf import settings
6+
from django.core.cache import BaseCache, caches
7+
8+
logger = logging.getLogger(__name__)
29

310

411
def get_setting_for_sso_op(op_name: str, key: str, default=None):
@@ -10,3 +17,42 @@ def get_setting_for_sso_op(op_name: str, key: str, default=None):
1017

1118
def get_settings_for_sso_op(op_name: str):
1219
return settings.DJANGO_PYOIDC[op_name]
20+
21+
22+
class OIDCCacheBackendForDjango:
23+
"""Implement General cache for OIDC using django cache"""
24+
25+
def __init__(self, op_name):
26+
self.op_name = op_name
27+
self.enabled = get_setting_for_sso_op(
28+
self.op_name, "OIDC_CACHE_PROVIDER_METADATA", False
29+
)
30+
if self.enabled:
31+
self.storage: BaseCache = caches[
32+
get_setting_for_sso_op(self.op_name, "CACHE_DJANGO_BACKEND")
33+
]
34+
35+
def generate_hashed_cache_key(self, value: str) -> str:
36+
h = hashlib.new("sha256")
37+
h.update(value.encode())
38+
cache_key = h.hexdigest()
39+
return cache_key
40+
41+
def clear(self):
42+
return self.storage.clear()
43+
44+
def get_key(self, key):
45+
return f"oidc-{self.op_name}-{key}"
46+
47+
def set(self, key: str, value: Dict[str, Union[str, bool]], expiry: int) -> None:
48+
if self.enabled:
49+
self.storage.set(self.get_key(key), value, expiry)
50+
51+
def __getitem__(self, key: str) -> Dict[str, Union[str, bool]]:
52+
if self.enabled:
53+
data = self.storage.get(self.get_key(key))
54+
if data is None:
55+
raise KeyError # Makes __getItem__ handle like Python dict
56+
return data
57+
else:
58+
raise KeyError

django_pyoidc/views.py

+30-16
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import datetime
2-
import hashlib
32
import logging
43
from importlib import import_module
54

65
# import oic
76
from django.conf import settings
87
from django.contrib import auth, messages
9-
from django.core.cache import caches
108
from django.core.exceptions import PermissionDenied, SuspiciousOperation
119
from django.http import HttpResponse
1210
from django.shortcuts import redirect, resolve_url
@@ -23,7 +21,11 @@
2321
from django_pyoidc import get_user_by_email
2422
from django_pyoidc.models import OIDCSession
2523
from django_pyoidc.session import OIDCCacheSessionBackendForDjango
26-
from django_pyoidc.utils import get_setting_for_sso_op, get_settings_for_sso_op
24+
from django_pyoidc.utils import (
25+
OIDCCacheBackendForDjango,
26+
get_setting_for_sso_op,
27+
get_settings_for_sso_op,
28+
)
2729

2830
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
2931

@@ -48,7 +50,8 @@ class OIDClient:
4850
def __init__(self, op_name, session_id=None):
4951
self._op_name = op_name
5052

51-
self.cache_backend = OIDCCacheSessionBackendForDjango(self._op_name)
53+
self.session_cache_backend = OIDCCacheSessionBackendForDjango(self._op_name)
54+
self.general_cache_backend = OIDCCacheBackendForDjango(self._op_name)
5255

5356
consumer_config = {
5457
# "debug": True,
@@ -61,7 +64,7 @@ def __init__(self, op_name, session_id=None):
6164
}
6265

6366
self.consumer = Consumer(
64-
session_db=self.cache_backend,
67+
session_db=self.session_cache_backend,
6568
consumer_config=consumer_config,
6669
client_config=client_config,
6770
)
@@ -72,13 +75,22 @@ def __init__(self, op_name, session_id=None):
7275
op_name, "OIDC_PROVIDER_DISCOVERY_URI"
7376
)
7477
client_secret = get_setting_for_sso_op(op_name, "OIDC_CLIENT_SECRET")
75-
# self.client_extension.provider_config(provider_info_uri)
7678
self.client_extension.client_secret = client_secret
7779

7880
if session_id:
7981
self.consumer.restore(session_id)
8082
else:
81-
self.consumer.provider_config(provider_info_uri)
83+
84+
cache_key = self.general_cache_backend.generate_hashed_cache_key(
85+
provider_info_uri
86+
)
87+
try:
88+
config = self.general_cache_backend[cache_key]
89+
except KeyError:
90+
config = self.consumer.provider_config(provider_info_uri)
91+
# shared microcache for provider config
92+
# FIXME: Setting for duration
93+
self.general_cache_backend.set(cache_key, config, 60)
8294
self.consumer.client_secret = client_secret
8395

8496

@@ -367,6 +379,10 @@ class OIDCCallbackView(OIDCView):
367379

368380
http_method_names = ["get"]
369381

382+
def __init__(self, **kwargs):
383+
super().__init__(**kwargs)
384+
self.general_cache_backend = OIDCCacheBackendForDjango(self.op_name)
385+
370386
def success_url(self, request):
371387
# Pull the next url from the session or settings --we don't need to
372388
# sanitize here because it should already have been sanitized.
@@ -390,14 +406,12 @@ def _introspect_access_token(self, access_token_jwt):
390406
# FIXME: in what case could we not have an access token available?
391407
# should we raise an error then?
392408
if access_token_jwt is not None:
393-
cache = caches[self.get_setting("CACHE_DJANGO_BACKEND")]
394-
h = hashlib.new("sha256")
395-
h.update(access_token_jwt.encode())
396-
cache_key = h.hexdigest()
397-
398-
access_token_claims = cache.get("cache_key")
399-
400-
if access_token_claims is None:
409+
cache_key = self.general_cache_backend.generate_hashed_cache_key(
410+
access_token_jwt
411+
)
412+
try:
413+
access_token_claims = self.general_cache_backend["cache_key"]
414+
except KeyError:
401415
# CACHE MISS
402416

403417
# RFC 7662: token introspection: ask SSO to validate and render the jwt as json
@@ -426,7 +440,7 @@ def _introspect_access_token(self, access_token_jwt):
426440
print(access_token_expiry)
427441
exp = int(access_token_expiry) - int(current)
428442
print(exp)
429-
cache.set(cache_key, access_token_claims, exp)
443+
self.general_cache_backend.set(cache_key, access_token_claims, exp)
430444
return access_token_claims
431445

432446
def get(self, request, *args, **kwargs):

tests/e2e/tests_keycloak.py

-97
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@
1616

1717
from tests.e2e.utils import OIDCE2EKeycloakTestCase
1818

19-
# , wrap_class
20-
2119
# HTTP debug for requests
2220
http_client.HTTPConnection.debuglevel = 1
2321

@@ -526,98 +524,3 @@ def test_06_selenium_minimal_audience_checks(self, *args):
526524
# Check the session message is shown
527525
self.assertTrue("message: [email protected] is logged in." in bodyText)
528526
self._selenium_logout(end_url)
529-
530-
531-
# @override_settings(
532-
# DJANGO_PYOIDC={
533-
# "sso1": {
534-
# "OIDC_CLIENT_ID": "app1",
535-
# "CACHE_DJANGO_BACKEND": "default",
536-
# "OIDC_PROVIDER_DISCOVERY_URI": "http://localhost:8080/auth/realms/realm1",
537-
# "OIDC_CLIENT_SECRET": "secret_app1",
538-
# "OIDC_CALLBACK_PATH": "/callback",
539-
# "LOGIN_URIS_REDIRECT_ALLOWED_HOSTS": ["testserver"],
540-
# "REDIRECT_REQUIRES_HTTPS": False,
541-
# "POST_LOGOUT_REDIRECT_URI": "/test-logout-done",
542-
# "POST_LOGIN_URI_SUCCESS": "/test-success",
543-
# "POST_LOGIN_URI_FAILURE": "/test-failure",
544-
# "HOOK_GET_USER": "tests.e2e.test_app.callback:get_user",
545-
# },
546-
# },
547-
# )
548-
# def test_10_selenium_pyoidc_provider_config_calls(self, *args):
549-
# """
550-
# FIXME:
551-
# Ensure calls to SSO provider configuration is managed with a cache.
552-
#
553-
# Several logins should not generates a call to provider config each time.
554-
# Provider configuration should be shared between users, at least for a
555-
# short cache lifetime duration.
556-
#
557-
# FIXME:
558-
# For pyoidc this means the Client must be feed with provider_info data
559-
# to avoid re-requesting it in auto discovery mode.
560-
# i.e. keys client_registration and provider_info in
561-
# ["srv_discovery_url", "client_info", "client_registration", "provider_info"].
562-
# "srv_discovery_url" should only be used when no cache data is available.
563-
# """
564-
# timeout = 5
565-
#
566-
# login_url = reverse("test_login")
567-
# success_url = reverse("test_success")
568-
# post_logout_url = reverse("test_logout_done")
569-
# start_url = f"{self.live_server_url}{login_url}"
570-
# middle_url = f"{self.live_server_url}{success_url}"
571-
# end_url = f"{self.live_server_url}{post_logout_url}"
572-
# from oic.oauth2 import Client
573-
#
574-
# with wrap_class(Client, "provider_config") as mocked_provider_config:
575-
#
576-
# self.wait = WebDriverWait(self.selenium, timeout)
577-
# self._selenium_sso_login(
578-
# start_url,
579-
# middle_url,
580-
# "user_limit_app2",
581-
# "passwd2",
582-
# active_sso_session=False,
583-
# )
584-
#
585-
# bodyText = self.selenium.find_element(By.TAG_NAME, "body").text
586-
#
587-
# # Check the session message is shown
588-
# self.assertTrue(
589-
# "message: [email protected] is logged in." in bodyText
590-
# )
591-
#
592-
# self._selenium_logout(end_url)
593-
#
594-
# self._selenium_sso_login(
595-
# start_url,
596-
# middle_url,
597-
# "user_limit_app1",
598-
# "passwd1",
599-
# active_sso_session=False,
600-
# )
601-
#
602-
# bodyText = self.selenium.find_element(By.TAG_NAME, "body").text
603-
#
604-
# # Check the session message is shown
605-
# self.assertTrue(
606-
# "message: [email protected] is logged in." in bodyText
607-
# )
608-
#
609-
# self._selenium_logout(end_url)
610-
#
611-
# # oic.oauth2.base.PBase.http_request:
612-
# # expected_call= [
613-
# # call('http://localhost:8080/auth/realms/realm1/.well-known/openid-configuration', allow_redirects=True)
614-
# # ]
615-
# print("==================================")
616-
# print(mocked_provider_config.call_count)
617-
# print(mocked_provider_config.call_args_list)
618-
# # mocked_provider_config.assert_any_call(expected_calls)
619-
#
620-
# # Need to set this test alone, maybe with a simplier Keycloak setUp...
621-
# # self.assertEquals( mocked_provider_config.call_count, 1)
622-
# self.assertEquals(mocked_provider_config.call_count, 2)
623-
#

tests/tests_session.py

+57-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from unittest import mock
2-
from unittest.mock import ANY, call
2+
from unittest.mock import call
33

4+
from django_pyoidc.utils import OIDCCacheBackendForDjango
45
from django_pyoidc.views import OIDClient
56
from tests.utils import OIDCTestCase
67

@@ -14,10 +15,62 @@ def test_session_isolation_between_providers(self, mocked_provider_config):
1415
sso1 = OIDClient(op_name="sso1")
1516
sso2 = OIDClient(op_name="sso2")
1617

18+
mocked_provider_config.assert_has_calls([call(""), call("")])
19+
assert 2 == mocked_provider_config.call_count
20+
21+
sso1.consumer._backup(sid="1234")
22+
sso2.consumer._backup(sid="1234")
23+
24+
# no more calls
25+
mocked_provider_config.assert_has_calls([call(""), call("")])
26+
assert 2 == mocked_provider_config.call_count
27+
28+
client1 = OIDClient(op_name="sso1", session_id="1234")
29+
self.assertEqual(client1.consumer.client_id, "1")
30+
31+
client2 = OIDClient(op_name="sso2", session_id="1234")
32+
33+
# no more calls
34+
mocked_provider_config.assert_has_calls([call(""), call("")])
35+
assert 2 == mocked_provider_config.call_count
36+
37+
self.assertEqual(client2.consumer.client_id, "2")
38+
39+
@mock.patch(
40+
"django_pyoidc.views.Consumer.provider_config",
41+
return_value=('[{"foo": "bar"}]'),
42+
)
43+
def test_session_isolation_between_providers_cached(self, mocked_provider_config):
44+
"""
45+
Test that different SSO providers with active cache using same SID do not conflict
46+
"""
47+
48+
# empty the caches
49+
cache1 = OIDCCacheBackendForDjango("sso3")
50+
cache2 = OIDCCacheBackendForDjango("sso4")
51+
cache1.clear()
52+
cache2.clear()
53+
54+
sso1 = OIDClient(op_name="sso3")
55+
mocked_provider_config.assert_has_calls([call("http://sso3/uri")])
56+
assert 1 == mocked_provider_config.call_count
57+
58+
sso2 = OIDClient(op_name="sso4")
59+
mocked_provider_config.assert_has_calls(
60+
[call("http://sso3/uri"), call("http://sso4/uri")]
61+
)
62+
assert 2 == mocked_provider_config.call_count
63+
1764
sso1.consumer._backup(sid="1234")
1865
sso2.consumer._backup(sid="1234")
1966

20-
client_new = OIDClient(op_name="sso1", session_id="1234")
67+
client1 = OIDClient(op_name="sso3", session_id="1234")
68+
self.assertEqual(client1.consumer.client_id, "3")
69+
70+
client2 = OIDClient(op_name="sso4", session_id="1234")
71+
self.assertEqual(client2.consumer.client_id, "4")
2172

22-
mocked_provider_config.assert_has_calls([call(ANY), call(ANY)])
23-
self.assertEqual(client_new.consumer.client_id, "1")
73+
mocked_provider_config.assert_has_calls(
74+
[call("http://sso3/uri"), call("http://sso4/uri")]
75+
)
76+
assert 2 == mocked_provider_config.call_count

tests/utils.py

+16
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
@override_settings(
99
DJANGO_PYOIDC={
1010
"sso1": {
11+
"OIDC_CACHE_PROVIDER_METADATA": False,
1112
"OIDC_CLIENT_ID": "1",
1213
"CACHE_DJANGO_BACKEND": "default",
1314
"OIDC_PROVIDER_DISCOVERY_URI": "",
@@ -20,11 +21,26 @@
2021
"POST_LOGIN_URI_FAILURE": "/logout_failure",
2122
},
2223
"sso2": {
24+
"OIDC_CACHE_PROVIDER_METADATA": False,
2325
"OIDC_CLIENT_ID": "2",
2426
"CACHE_DJANGO_BACKEND": "default",
2527
"OIDC_PROVIDER_DISCOVERY_URI": "",
2628
"OIDC_CLIENT_SECRET": "",
2729
},
30+
"sso3": {
31+
"OIDC_CACHE_PROVIDER_METADATA": True,
32+
"OIDC_CLIENT_ID": "3",
33+
"CACHE_DJANGO_BACKEND": "default",
34+
"OIDC_PROVIDER_DISCOVERY_URI": "http://sso3/uri",
35+
"OIDC_CLIENT_SECRET": "",
36+
},
37+
"sso4": {
38+
"OIDC_CACHE_PROVIDER_METADATA": True,
39+
"OIDC_CLIENT_ID": "4",
40+
"CACHE_DJANGO_BACKEND": "default",
41+
"OIDC_PROVIDER_DISCOVERY_URI": "http://sso4/uri",
42+
"OIDC_CLIENT_SECRET": "",
43+
},
2844
}
2945
)
3046
class OIDCTestCase(TestCase):

0 commit comments

Comments
 (0)