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
1=== modified file 'django_project/settings_base.py'
2--- django_project/settings_base.py 2018-07-12 16:40:37 +0000
3+++ django_project/settings_base.py 2018-09-05 14:18:47 +0000
4@@ -442,6 +442,7 @@
5 OPENID_LAUNCHPAD_TEAMS_REQUIRED = []
6 OPENID_PHYSICAL_MULTIFACTOR_REQUIRED = False
7 OPENID_PREAUTHORIZATION_ACL = []
8+OPENID_TOKEN_TTL = 24 * 60 * 60 # 1 day, seconds
9 OPENID_SREG_EXTRA_FIELDS = ['language']
10 OPENID_SREG_REQUIRED_FIELDS = []
11 OPENID_SSO_SERVER_URL = SSO_ROOT_URL
12
13=== modified file 'src/identityprovider/tests/test_views_server.py'
14--- src/identityprovider/tests/test_views_server.py 2018-05-28 19:44:56 +0000
15+++ src/identityprovider/tests/test_views_server.py 2018-09-05 14:18:47 +0000
16@@ -1,9 +1,10 @@
17 # -*- coding: utf-8 -*-
18-# Copyright 2010-2016 Canonical Ltd. This software is licensed under
19+# Copyright 2010-2018 Canonical Ltd. This software is licensed under
20 # the GNU Affero General Public License version 3 (see the file
21 # LICENSE).
22
23 import datetime
24+import time
25 import urlparse
26
27 from urllib import quote, quote_plus, urlencode
28@@ -181,6 +182,7 @@
29 quote_plus(self.account.displayname))
30 self.assertEqual(query['openid.ax.value.account_verified.1'],
31 'token_via_email')
32+ self.assertIn('token_expirations', r.client.session)
33
34 def test_handle_user_response_auto_auth_large_ax_sreg_response(self):
35 # Make sure we get a large response
36@@ -252,6 +254,7 @@
37 self.account.get_absolute_url()))
38 self.assertEqual(query['openid.identity'], openid_identity_url)
39 self.assertEqual(query['openid.claimed_id'], openid_identity_url)
40+ self.assertIn('token_expirations', response.client.session)
41
42 def _test_auto_auth(self, ax=None, sreg=None,
43 root_macaroon=None, macaroon_key=None):
44@@ -342,6 +345,7 @@
45 self.assertNotIn(k, forms[0].fields)
46 for k in ('openid.assoc_handle', 'openid.sig'):
47 self.assertIn(k, forms[0].fields)
48+ self.assertIn('token_expirations', response.client.session)
49
50 def test_handle_user_response_openid_is_authorized_idselect(self):
51 # update rp to auto authorize
52@@ -357,6 +361,7 @@
53 self.account.get_absolute_url())
54 self.assertEqual(query['openid.identity'],
55 quote_plus(openid_identity_url))
56+ self.assertIn('token_expirations', r.client.session)
57
58 def test_handle_user_response_openid_is_authorized_other_id(self):
59 self.rpconfig.auto_authorize = True
60@@ -370,6 +375,7 @@
61 self.assertEqual(query['openid.mode'], 'id_res')
62 self.assertEqual(query['openid.identity'],
63 quote_plus(self.params['openid.identity']))
64+ self.assertIn('token_expirations', r.client.session)
65
66 def test_handle_user_response_user_is_authenticated(self):
67 url = self.request.build_absolute_uri(
68@@ -380,12 +386,19 @@
69 query = self.get_query(r)
70 self.assertEqual(r.status_code, 302)
71 self.assertEqual(query['openid.mode'], 'cancel')
72+ self.assertIn('token_expirations', r.client.session)
73
74 def test_handle_user_response_decide(self):
75 r = self.client.get(self.url, self.params)
76 self.assertEqual(r.status_code, 302)
77 self.assertTrue(r['Location'].endswith('+decide'))
78
79+ session = r.client.session
80+ self.assertIn('token_expirations', session)
81+ self.assertEqual(len(session['token_expirations']), 1)
82+ self.assertIn(session['token_expirations'].keys()[0], session)
83+ self.assertIn('token_expirations', r.client.session)
84+
85 def test_handle_user_response_with_referer(self):
86 META = {'HTTP_REFERER': 'http://localhost/'}
87 r = self.client.get(self.url, self.params, **META)
88@@ -393,6 +406,7 @@
89 self.assertEqual(r.status_code, 302)
90 self.assertTrue(r['Location'].endswith('+decide'))
91 self.assertEqual(openid_referer.value, META['HTTP_REFERER'])
92+ self.assertIn('token_expirations', r.client.session)
93
94 def get_login_after_redirect_from_consumer(self):
95 self.rpconfig.logo = 'http://someserver/logo.png'
96@@ -423,6 +437,7 @@
97 r = self.client.post(login_url)
98 # rpconfig should be refetched
99 self.assertEqual(r.context['rpconfig'], self.rpconfig)
100+ self.assertIn('token_expirations', r.client.session)
101
102
103 class HandleUserResponseUnverifiedUserLogedInTestCase(SSOBaseTestCase):
104@@ -627,6 +642,98 @@
105 self.assertFalse(valid)
106
107
108+class ExpiredTokensHandlerTestCase(SSOBaseTestCase):
109+
110+ def _generate_openid_token(self, session, token=None, expiration=None):
111+ if token is None:
112+ token = generate_random_string(16)
113+ session[token] = signed.dumps('orequest', settings.SECRET_KEY)
114+ if expiration:
115+ if 'token_expirations' not in session:
116+ session['token_expirations'] = {}
117+ session['token_expirations'][token] = expiration
118+ return token
119+
120+ def test_session_with_no_token_expirations(self):
121+ session = self.client.session
122+ self.assertNotIn('token_expirations', session)
123+
124+ server.handle_expired_openid_tokens(session)
125+
126+ self.assertIn('token_expirations', session)
127+
128+ def test_token_with_no_expiration(self):
129+ session = self.client.session
130+ token = self._generate_openid_token(session)
131+ self.assertIn(token, session)
132+ self.assertNotIn('token_expirations', session)
133+
134+ server.handle_expired_openid_tokens(session)
135+
136+ self.assertIn(token, session['token_expirations'])
137+
138+ def test_unexpired_token(self):
139+ session = self.client.session
140+ expiration = int(time.time()) + 60 # 1m into the future
141+ token = self._generate_openid_token(session, expiration=expiration)
142+ self.assertIn(token, session)
143+ self.assertEqual(session['token_expirations'][token], expiration)
144+
145+ # Simulate an old session.
146+ session.modified = False
147+ server.handle_expired_openid_tokens(session)
148+
149+ self.assertIn(token, session)
150+ self.assertIn(token, session['token_expirations'])
151+ self.assertEqual(session['token_expirations'][token], expiration)
152+ self.assertTrue(session.modified)
153+
154+ def test_expired_token(self):
155+ session = self.client.session
156+ expiration = int(time.time()) - 1 # 1s ago
157+ token = self._generate_openid_token(session, expiration=expiration)
158+ self.assertIn(token, session)
159+ self.assertEqual(session['token_expirations'][token], expiration)
160+
161+ # Simulate an old session.
162+ session.modified = False
163+ server.handle_expired_openid_tokens(session)
164+
165+ self.assertNotIn(token, session)
166+ self.assertNotIn(token, session['token_expirations'])
167+ self.assertTrue(session.modified)
168+
169+ def test_non_token_keys(self):
170+ session = self.client.session
171+ for i in range(1, 33):
172+ token = 'a' * i
173+ self._generate_openid_token(session, token=token)
174+
175+ server.handle_expired_openid_tokens(session)
176+
177+ # Only the token with len 16 should be included in 'token_expirations'.
178+ self.assertIn('token_expirations', session)
179+ self.assertEqual(len(session['token_expirations']), 1)
180+ self.assertEqual(len(session['token_expirations'].keys()[0]), 16)
181+
182+ def test_non_token_values(self):
183+ session = self.client.session
184+ for i, value in enumerate([
185+ None,
186+ '',
187+ [],
188+ {},
189+ 'a' * 50, # length of valid signed token
190+ ['asdf'],
191+ {'a': 1, 'b': 2}
192+ ]):
193+ session[str(i) * 16] = value
194+
195+ server.handle_expired_openid_tokens(session)
196+ self.assertIn('token_expirations', session)
197+ self.assertFalse(session['token_expirations'])
198+
199+
200 class DecideBaseTestCase(AuthenticatedTestCase):
201
202 def setUp(self):
203@@ -749,6 +856,11 @@
204 self.assertEqual(r.status_code, 200)
205 self.assertTemplateUsed(r, 'registration/login.html')
206
207+ session = r.client.session
208+ self.assertIn('token_expirations', session)
209+ self.assertEqual(len(session['token_expirations']), 1)
210+ self.assertIn(session['token_expirations'].keys()[0], session)
211+
212 def test_decide_multiple_openidrpsummary(self):
213 # create multiple matching OpenIDRPSummary objects
214 trust_root = 'http://localhost/'
215@@ -1923,6 +2035,23 @@
216 self.assertEqual(r.status_code, 200)
217 self.assertTemplateUsed(r, 'server/untrusted.html')
218
219+ def test_token_expiration(self):
220+ params = {'openid.mode': 'checkid_setup',
221+ 'openid.trust_root': 'http://localhost/',
222+ 'openid.return_to': 'http://localhost/',
223+ 'openid.identity': IDENTIFIER_SELECT}
224+ request = self.factory.make_request(**params)
225+ provider_url = get_provider_url(request)
226+
227+ self.assertNotIn('token_expirations', self.client.session)
228+
229+ response = self.client.get(provider_url, params)
230+
231+ session = response.client.session
232+ self.assertIn('token_expirations', session)
233+ self.assertEqual(len(session['token_expirations']), 1)
234+ self.assertIn(session['token_expirations'].keys()[0], session)
235+
236
237 class MarkupTestCase(AuthenticatedTestCase):
238
239
240=== modified file 'src/identityprovider/views/server.py'
241--- src/identityprovider/views/server.py 2018-05-28 19:44:56 +0000
242+++ src/identityprovider/views/server.py 2018-09-05 14:18:47 +0000
243@@ -1,9 +1,10 @@
244-# Copyright 2010-2016 Canonical Ltd. This software is licensed under
245+# Copyright 2010-2018 Canonical Ltd. This software is licensed under
246 # the GNU Affero General Public License version 3 (see the file
247 # LICENSE).
248
249 import logging
250 import re
251+import time
252 import urllib
253 import urlparse
254 from datetime import timedelta
255@@ -140,6 +141,9 @@
256 except ProtocolError as e:
257 response = _handle_openid_error(e)
258 set_language_info(request, response, lang)
259+ # Prevent expired tokens from accumulating needlessly.
260+ if hasattr(request, 'session'):
261+ handle_expired_openid_tokens(request.session)
262 return response
263
264
265@@ -263,6 +267,35 @@
266 return False
267
268
269+def handle_expired_openid_tokens(session):
270+ now_ts = int(time.time())
271+
272+ # Ensure session has expirations
273+ if 'token_expirations' not in session:
274+ session['token_expirations'] = {}
275+
276+ # Remove expired tokens
277+ token_expirations = session['token_expirations']
278+ for token, expiration in token_expirations.items():
279+ if now_ts > expiration:
280+ session.pop(token, None)
281+ token_expirations.pop(token, None)
282+
283+ # Set a 24h expiration to tokens with no expiration.
284+ for key, value in session.items():
285+ if (len(key) == 16 and key not in token_expirations):
286+ try:
287+ # Check if value is actually a valid token.
288+ signed.loads(value, settings.SECRET_KEY)
289+ token_expirations[key] = now_ts + settings.OPENID_TOKEN_TTL
290+ except (TypeError, ValueError):
291+ # Not a valid token; ignore.
292+ pass
293+
294+ # Make sure the session is persisted.
295+ session.modified = True
296+
297+
298 def decide(request, token):
299 try:
300 orequest = utils.get_orequest(request, token)