Merge lp:~maxiberta/canonical-identity-provider/openid-session-ttl into lp:canonical-identity-provider/release

Proposed by Maximiliano Bertacchini
Status: Merged
Approved by: Maximiliano Bertacchini
Approved revision: no longer in the source branch.
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: lp:~maxiberta/canonical-identity-provider/openid-session-ttl
Merge into: lp:canonical-identity-provider/release
Diff against target: 300 lines (+165/-2)
3 files modified
django_project/settings_base.py (+1/-0)
src/identityprovider/tests/test_views_server.py (+130/-1)
src/identityprovider/views/server.py (+34/-1)
To merge this branch: bzr merge lp:~maxiberta/canonical-identity-provider/openid-session-ttl
Reviewer Review Type Date Requested Status
Daniel Manrique (community) Approve
Review via email: mp+354127@code.launchpad.net

Commit message

Expire old OpenID tokens from user sessions.

Description of the change

Tracks openid token expiration in each session (session['token_expirations'], default 24h since creation), and deletes old ones on every request to /+openid. Old tokens are assigned a 24h ttl when first seen.

To post a comment you must log in.
Revision history for this message
Daniel Manrique (roadmr) wrote :

LGTM, my only nitpick is that 24 * 60 * 60 is harder for me to read than 86400, a well-known constant for "seconds in a day". But I acknowledge the 24 * 60 * 60 variant is widely used in this codebase, so let's keep it for consistency.

review: Approve
Revision history for this message
Maximiliano Bertacchini (maxiberta) :
Revision history for this message
Daniel Manrique (roadmr) wrote :

Thanks!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'django_project/settings_base.py'
--- django_project/settings_base.py 2018-07-12 16:40:37 +0000
+++ django_project/settings_base.py 2018-09-05 14:18:47 +0000
@@ -442,6 +442,7 @@
442OPENID_LAUNCHPAD_TEAMS_REQUIRED = []442OPENID_LAUNCHPAD_TEAMS_REQUIRED = []
443OPENID_PHYSICAL_MULTIFACTOR_REQUIRED = False443OPENID_PHYSICAL_MULTIFACTOR_REQUIRED = False
444OPENID_PREAUTHORIZATION_ACL = []444OPENID_PREAUTHORIZATION_ACL = []
445OPENID_TOKEN_TTL = 24 * 60 * 60 # 1 day, seconds
445OPENID_SREG_EXTRA_FIELDS = ['language']446OPENID_SREG_EXTRA_FIELDS = ['language']
446OPENID_SREG_REQUIRED_FIELDS = []447OPENID_SREG_REQUIRED_FIELDS = []
447OPENID_SSO_SERVER_URL = SSO_ROOT_URL448OPENID_SSO_SERVER_URL = SSO_ROOT_URL
448449
=== modified file 'src/identityprovider/tests/test_views_server.py'
--- src/identityprovider/tests/test_views_server.py 2018-05-28 19:44:56 +0000
+++ src/identityprovider/tests/test_views_server.py 2018-09-05 14:18:47 +0000
@@ -1,9 +1,10 @@
1# -*- coding: utf-8 -*-1# -*- coding: utf-8 -*-
2# Copyright 2010-2016 Canonical Ltd. This software is licensed under2# Copyright 2010-2018 Canonical Ltd. This software is licensed under
3# the GNU Affero General Public License version 3 (see the file3# the GNU Affero General Public License version 3 (see the file
4# LICENSE).4# LICENSE).
55
6import datetime6import datetime
7import time
7import urlparse8import urlparse
89
9from urllib import quote, quote_plus, urlencode10from urllib import quote, quote_plus, urlencode
@@ -181,6 +182,7 @@
181 quote_plus(self.account.displayname))182 quote_plus(self.account.displayname))
182 self.assertEqual(query['openid.ax.value.account_verified.1'],183 self.assertEqual(query['openid.ax.value.account_verified.1'],
183 'token_via_email')184 'token_via_email')
185 self.assertIn('token_expirations', r.client.session)
184186
185 def test_handle_user_response_auto_auth_large_ax_sreg_response(self):187 def test_handle_user_response_auto_auth_large_ax_sreg_response(self):
186 # Make sure we get a large response188 # Make sure we get a large response
@@ -252,6 +254,7 @@
252 self.account.get_absolute_url()))254 self.account.get_absolute_url()))
253 self.assertEqual(query['openid.identity'], openid_identity_url)255 self.assertEqual(query['openid.identity'], openid_identity_url)
254 self.assertEqual(query['openid.claimed_id'], openid_identity_url)256 self.assertEqual(query['openid.claimed_id'], openid_identity_url)
257 self.assertIn('token_expirations', response.client.session)
255258
256 def _test_auto_auth(self, ax=None, sreg=None,259 def _test_auto_auth(self, ax=None, sreg=None,
257 root_macaroon=None, macaroon_key=None):260 root_macaroon=None, macaroon_key=None):
@@ -342,6 +345,7 @@
342 self.assertNotIn(k, forms[0].fields)345 self.assertNotIn(k, forms[0].fields)
343 for k in ('openid.assoc_handle', 'openid.sig'):346 for k in ('openid.assoc_handle', 'openid.sig'):
344 self.assertIn(k, forms[0].fields)347 self.assertIn(k, forms[0].fields)
348 self.assertIn('token_expirations', response.client.session)
345349
346 def test_handle_user_response_openid_is_authorized_idselect(self):350 def test_handle_user_response_openid_is_authorized_idselect(self):
347 # update rp to auto authorize351 # update rp to auto authorize
@@ -357,6 +361,7 @@
357 self.account.get_absolute_url())361 self.account.get_absolute_url())
358 self.assertEqual(query['openid.identity'],362 self.assertEqual(query['openid.identity'],
359 quote_plus(openid_identity_url))363 quote_plus(openid_identity_url))
364 self.assertIn('token_expirations', r.client.session)
360365
361 def test_handle_user_response_openid_is_authorized_other_id(self):366 def test_handle_user_response_openid_is_authorized_other_id(self):
362 self.rpconfig.auto_authorize = True367 self.rpconfig.auto_authorize = True
@@ -370,6 +375,7 @@
370 self.assertEqual(query['openid.mode'], 'id_res')375 self.assertEqual(query['openid.mode'], 'id_res')
371 self.assertEqual(query['openid.identity'],376 self.assertEqual(query['openid.identity'],
372 quote_plus(self.params['openid.identity']))377 quote_plus(self.params['openid.identity']))
378 self.assertIn('token_expirations', r.client.session)
373379
374 def test_handle_user_response_user_is_authenticated(self):380 def test_handle_user_response_user_is_authenticated(self):
375 url = self.request.build_absolute_uri(381 url = self.request.build_absolute_uri(
@@ -380,12 +386,19 @@
380 query = self.get_query(r)386 query = self.get_query(r)
381 self.assertEqual(r.status_code, 302)387 self.assertEqual(r.status_code, 302)
382 self.assertEqual(query['openid.mode'], 'cancel')388 self.assertEqual(query['openid.mode'], 'cancel')
389 self.assertIn('token_expirations', r.client.session)
383390
384 def test_handle_user_response_decide(self):391 def test_handle_user_response_decide(self):
385 r = self.client.get(self.url, self.params)392 r = self.client.get(self.url, self.params)
386 self.assertEqual(r.status_code, 302)393 self.assertEqual(r.status_code, 302)
387 self.assertTrue(r['Location'].endswith('+decide'))394 self.assertTrue(r['Location'].endswith('+decide'))
388395
396 session = r.client.session
397 self.assertIn('token_expirations', session)
398 self.assertEqual(len(session['token_expirations']), 1)
399 self.assertIn(session['token_expirations'].keys()[0], session)
400 self.assertIn('token_expirations', r.client.session)
401
389 def test_handle_user_response_with_referer(self):402 def test_handle_user_response_with_referer(self):
390 META = {'HTTP_REFERER': 'http://localhost/'}403 META = {'HTTP_REFERER': 'http://localhost/'}
391 r = self.client.get(self.url, self.params, **META)404 r = self.client.get(self.url, self.params, **META)
@@ -393,6 +406,7 @@
393 self.assertEqual(r.status_code, 302)406 self.assertEqual(r.status_code, 302)
394 self.assertTrue(r['Location'].endswith('+decide'))407 self.assertTrue(r['Location'].endswith('+decide'))
395 self.assertEqual(openid_referer.value, META['HTTP_REFERER'])408 self.assertEqual(openid_referer.value, META['HTTP_REFERER'])
409 self.assertIn('token_expirations', r.client.session)
396410
397 def get_login_after_redirect_from_consumer(self):411 def get_login_after_redirect_from_consumer(self):
398 self.rpconfig.logo = 'http://someserver/logo.png'412 self.rpconfig.logo = 'http://someserver/logo.png'
@@ -423,6 +437,7 @@
423 r = self.client.post(login_url)437 r = self.client.post(login_url)
424 # rpconfig should be refetched438 # rpconfig should be refetched
425 self.assertEqual(r.context['rpconfig'], self.rpconfig)439 self.assertEqual(r.context['rpconfig'], self.rpconfig)
440 self.assertIn('token_expirations', r.client.session)
426441
427442
428class HandleUserResponseUnverifiedUserLogedInTestCase(SSOBaseTestCase):443class HandleUserResponseUnverifiedUserLogedInTestCase(SSOBaseTestCase):
@@ -627,6 +642,98 @@
627 self.assertFalse(valid)642 self.assertFalse(valid)
628643
629644
645class ExpiredTokensHandlerTestCase(SSOBaseTestCase):
646
647 def _generate_openid_token(self, session, token=None, expiration=None):
648 if token is None:
649 token = generate_random_string(16)
650 session[token] = signed.dumps('orequest', settings.SECRET_KEY)
651 if expiration:
652 if 'token_expirations' not in session:
653 session['token_expirations'] = {}
654 session['token_expirations'][token] = expiration
655 return token
656
657 def test_session_with_no_token_expirations(self):
658 session = self.client.session
659 self.assertNotIn('token_expirations', session)
660
661 server.handle_expired_openid_tokens(session)
662
663 self.assertIn('token_expirations', session)
664
665 def test_token_with_no_expiration(self):
666 session = self.client.session
667 token = self._generate_openid_token(session)
668 self.assertIn(token, session)
669 self.assertNotIn('token_expirations', session)
670
671 server.handle_expired_openid_tokens(session)
672
673 self.assertIn(token, session['token_expirations'])
674
675 def test_unexpired_token(self):
676 session = self.client.session
677 expiration = int(time.time()) + 60 # 1m into the future
678 token = self._generate_openid_token(session, expiration=expiration)
679 self.assertIn(token, session)
680 self.assertEqual(session['token_expirations'][token], expiration)
681
682 # Simulate an old session.
683 session.modified = False
684 server.handle_expired_openid_tokens(session)
685
686 self.assertIn(token, session)
687 self.assertIn(token, session['token_expirations'])
688 self.assertEqual(session['token_expirations'][token], expiration)
689 self.assertTrue(session.modified)
690
691 def test_expired_token(self):
692 session = self.client.session
693 expiration = int(time.time()) - 1 # 1s ago
694 token = self._generate_openid_token(session, expiration=expiration)
695 self.assertIn(token, session)
696 self.assertEqual(session['token_expirations'][token], expiration)
697
698 # Simulate an old session.
699 session.modified = False
700 server.handle_expired_openid_tokens(session)
701
702 self.assertNotIn(token, session)
703 self.assertNotIn(token, session['token_expirations'])
704 self.assertTrue(session.modified)
705
706 def test_non_token_keys(self):
707 session = self.client.session
708 for i in range(1, 33):
709 token = 'a' * i
710 self._generate_openid_token(session, token=token)
711
712 server.handle_expired_openid_tokens(session)
713
714 # Only the token with len 16 should be included in 'token_expirations'.
715 self.assertIn('token_expirations', session)
716 self.assertEqual(len(session['token_expirations']), 1)
717 self.assertEqual(len(session['token_expirations'].keys()[0]), 16)
718
719 def test_non_token_values(self):
720 session = self.client.session
721 for i, value in enumerate([
722 None,
723 '',
724 [],
725 {},
726 'a' * 50, # length of valid signed token
727 ['asdf'],
728 {'a': 1, 'b': 2}
729 ]):
730 session[str(i) * 16] = value
731
732 server.handle_expired_openid_tokens(session)
733 self.assertIn('token_expirations', session)
734 self.assertFalse(session['token_expirations'])
735
736
630class DecideBaseTestCase(AuthenticatedTestCase):737class DecideBaseTestCase(AuthenticatedTestCase):
631738
632 def setUp(self):739 def setUp(self):
@@ -749,6 +856,11 @@
749 self.assertEqual(r.status_code, 200)856 self.assertEqual(r.status_code, 200)
750 self.assertTemplateUsed(r, 'registration/login.html')857 self.assertTemplateUsed(r, 'registration/login.html')
751858
859 session = r.client.session
860 self.assertIn('token_expirations', session)
861 self.assertEqual(len(session['token_expirations']), 1)
862 self.assertIn(session['token_expirations'].keys()[0], session)
863
752 def test_decide_multiple_openidrpsummary(self):864 def test_decide_multiple_openidrpsummary(self):
753 # create multiple matching OpenIDRPSummary objects865 # create multiple matching OpenIDRPSummary objects
754 trust_root = 'http://localhost/'866 trust_root = 'http://localhost/'
@@ -1923,6 +2035,23 @@
1923 self.assertEqual(r.status_code, 200)2035 self.assertEqual(r.status_code, 200)
1924 self.assertTemplateUsed(r, 'server/untrusted.html')2036 self.assertTemplateUsed(r, 'server/untrusted.html')
19252037
2038 def test_token_expiration(self):
2039 params = {'openid.mode': 'checkid_setup',
2040 'openid.trust_root': 'http://localhost/',
2041 'openid.return_to': 'http://localhost/',
2042 'openid.identity': IDENTIFIER_SELECT}
2043 request = self.factory.make_request(**params)
2044 provider_url = get_provider_url(request)
2045
2046 self.assertNotIn('token_expirations', self.client.session)
2047
2048 response = self.client.get(provider_url, params)
2049
2050 session = response.client.session
2051 self.assertIn('token_expirations', session)
2052 self.assertEqual(len(session['token_expirations']), 1)
2053 self.assertIn(session['token_expirations'].keys()[0], session)
2054
19262055
1927class MarkupTestCase(AuthenticatedTestCase):2056class MarkupTestCase(AuthenticatedTestCase):
19282057
19292058
=== modified file 'src/identityprovider/views/server.py'
--- src/identityprovider/views/server.py 2018-05-28 19:44:56 +0000
+++ src/identityprovider/views/server.py 2018-09-05 14:18:47 +0000
@@ -1,9 +1,10 @@
1# Copyright 2010-2016 Canonical Ltd. This software is licensed under1# Copyright 2010-2018 Canonical Ltd. This software is licensed under
2# the GNU Affero General Public License version 3 (see the file2# the GNU Affero General Public License version 3 (see the file
3# LICENSE).3# LICENSE).
44
5import logging5import logging
6import re6import re
7import time
7import urllib8import urllib
8import urlparse9import urlparse
9from datetime import timedelta10from datetime import timedelta
@@ -140,6 +141,9 @@
140 except ProtocolError as e:141 except ProtocolError as e:
141 response = _handle_openid_error(e)142 response = _handle_openid_error(e)
142 set_language_info(request, response, lang)143 set_language_info(request, response, lang)
144 # Prevent expired tokens from accumulating needlessly.
145 if hasattr(request, 'session'):
146 handle_expired_openid_tokens(request.session)
143 return response147 return response
144148
145149
@@ -263,6 +267,35 @@
263 return False267 return False
264268
265269
270def handle_expired_openid_tokens(session):
271 now_ts = int(time.time())
272
273 # Ensure session has expirations
274 if 'token_expirations' not in session:
275 session['token_expirations'] = {}
276
277 # Remove expired tokens
278 token_expirations = session['token_expirations']
279 for token, expiration in token_expirations.items():
280 if now_ts > expiration:
281 session.pop(token, None)
282 token_expirations.pop(token, None)
283
284 # Set a 24h expiration to tokens with no expiration.
285 for key, value in session.items():
286 if (len(key) == 16 and key not in token_expirations):
287 try:
288 # Check if value is actually a valid token.
289 signed.loads(value, settings.SECRET_KEY)
290 token_expirations[key] = now_ts + settings.OPENID_TOKEN_TTL
291 except (TypeError, ValueError):
292 # Not a valid token; ignore.
293 pass
294
295 # Make sure the session is persisted.
296 session.modified = True
297
298
266def decide(request, token):299def decide(request, token):
267 try:300 try:
268 orequest = utils.get_orequest(request, token)301 orequest = utils.get_orequest(request, token)