Merge lp:~gary/charms/precise/juju-gui/authtoken1 into lp:~juju-gui/charms/precise/juju-gui/trunk

Proposed by Gary Poster
Status: Merged
Merged at revision: 136
Proposed branch: lp:~gary/charms/precise/juju-gui/authtoken1
Merge into: lp:~juju-gui/charms/precise/juju-gui/trunk
Diff against target: 349 lines (+306/-4)
2 files modified
server/guiserver/auth.py (+143/-4)
server/guiserver/tests/test_auth.py (+163/-0)
To merge this branch: bzr merge lp:~gary/charms/precise/juju-gui/authtoken1
Reviewer Review Type Date Requested Status
charmers Pending
Review via email: mp+196347@code.launchpad.net

Description of the change

Add authentication token handler class

This is the first branch in a set of what will likely be three. This one simply creates the handler that we will use. There's no QA yet.

https://codereview.appspot.com/31020043/

To post a comment you must log in.
Revision history for this message
Gary Poster (gary) wrote :

Reviewers: mp+196347_code.launchpad.net,

Message:
Please take a look.

Description:
Add authentication token handler class

This is the first branch in a set of what will likely be three. This
one simply creates the handler that we will use. There's no QA yet.

https://code.launchpad.net/~gary/charms/precise/juju-gui/authtoken1/+merge/196347

(do not edit description out of merge proposal)

Please review this at https://codereview.appspot.com/31020043/

Affected files (+306, -4 lines):
   A [revision details]
   M server/guiserver/auth.py
   M server/guiserver/tests/test_auth.py

Revision history for this message
Francesco Banconi (frankban) wrote :

Nice branch Gary. Tests pass and are very nice.
LGTM with only minors/optional changes.
Thank you!

https://codereview.appspot.com/31020043/diff/1/server/guiserver/auth.py
File server/guiserver/auth.py (right):

https://codereview.appspot.com/31020043/diff/1/server/guiserver/auth.py#newcode236
server/guiserver/auth.py:236: Here is an example of a token creation
response. Lifetime is in seconds.
Does "Lifetime is in seconds" still apply?

https://codereview.appspot.com/31020043/diff/1/server/guiserver/auth.py#newcode325
server/guiserver/auth.py:325: """Does data represents a token creation
request? True or False."""
Does data represent a token authentication request?

https://codereview.appspot.com/31020043/diff/1/server/guiserver/tests/test_auth.py
File server/guiserver/tests/test_auth.py (right):

https://codereview.appspot.com/31020043/diff/1/server/guiserver/tests/test_auth.py#newcode325
server/guiserver/tests/test_auth.py:325:
self.io_loop.add_timeout.call_args[0][1]()
Nice! It took me a minute before understanding you are calling the
expire_token() function. Maybe we could retrieve the function and then
call it, or just add a comment?

https://codereview.appspot.com/31020043/diff/1/server/guiserver/tests/test_auth.py#newcode362
server/guiserver/tests/test_auth.py:362:
self.io_loop.remove_timeout.assert_called_with('handle marker')
assert_called_once_with? Very cheap double check.

https://codereview.appspot.com/31020043/diff/1/server/guiserver/tests/test_auth.py#newcode389
server/guiserver/tests/test_auth.py:389: # This is a small integration
test of the two functions' interaction.
Very nice!

https://codereview.appspot.com/31020043/

139. By Gary Poster

respond to review

140. By Gary Poster

correct grammar

Revision history for this message
Gary Poster (gary) wrote :

*** Submitted:

Add authentication token handler class

This is the first branch in a set of what will likely be three. This
one simply creates the handler that we will use. There's no QA yet.

R=frankban
CC=
https://codereview.appspot.com/31020043

https://codereview.appspot.com/31020043/

Revision history for this message
Gary Poster (gary) wrote :

Thank you for the very helpful review, Francesco, and for the elegant
pre-imp ideas that guided this branch.

https://codereview.appspot.com/31020043/

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'server/guiserver/auth.py'
--- server/guiserver/auth.py 2013-10-17 14:12:16 +0000
+++ server/guiserver/auth.py 2013-11-23 02:10:22 +0000
@@ -36,7 +36,11 @@
36 if the authentication succeeds.36 if the authentication succeeds.
37"""37"""
3838
39import datetime
39import logging40import logging
41import uuid
42
43from tornado.ioloop import IOLoop
4044
4145
42class User(object):46class User(object):
@@ -44,10 +48,6 @@
4448
45 def __init__(self, username='', password='', is_authenticated=False):49 def __init__(self, username='', password='', is_authenticated=False):
46 self.is_authenticated = is_authenticated50 self.is_authenticated = is_authenticated
47 # XXX (frankban) YAGNI: the username/password attributes are not
48 # required for now, but they will help handling the HA story, i.e. in
49 # the process of re-authenticating to the API after switching from one
50 # Juju state/API server to another.
51 self.username = username51 self.username = username
52 self.password = password52 self.password = password
5353
@@ -219,3 +219,142 @@
219 """Return the auth backend instance to use for the given API version."""219 """Return the auth backend instance to use for the given API version."""
220 backend_class = {'go': GoBackend, 'python': PythonBackend}[apiversion]220 backend_class = {'go': GoBackend, 'python': PythonBackend}[apiversion]
221 return backend_class()221 return backend_class()
222
223
224class AuthenticationTokenHandler(object):
225 """Handle requests related to authentication tokens.
226
227 A token creation request looks like the following:
228
229 {
230 'RequestId': 42,
231 'Type': 'GUIToken',
232 'Request': 'Create',
233 'Params': {},
234 }
235
236 Here is an example of a token creation response.
237
238 {
239 'RequestId': 42,
240 'Response': {
241 'Token': 'TOKEN-STRING',
242 'Created': '2013-11-21T12:34:46.778866Z',
243 'Expires': '2013-11-21T12:36:46.778866Z'
244 }
245 }
246
247 A token authentication request looks like the following:
248
249 {
250 'RequestId': 42,
251 'Type': 'GUIToken',
252 'Request': 'Login',
253 'Params': {'Token': 'TOKEN-STRING'},
254 }
255
256 Here is an example of a successful login response:
257
258 {
259 'RequestId': 42,
260 'Response': {'AuthTag': 'user-admin', 'Password': 'ADMIN-SECRET'}
261 }
262
263 A login failure response is like the following:
264
265 {
266 'RequestId': 42,
267 'Error': 'unknown, fulfilled, or expired token',
268 'ErrorCode': 'unauthorized access',
269 'Response': {},
270 }
271
272 Juju itself might return a failure response like the following, but this
273 would be difficult or impossible to trigger as of this writing:
274
275 {
276 'RequestId': 42,
277 'Error': 'invalid entity name or password',
278 'ErrorCode': 'unauthorized access',
279 'Response': {},
280 }
281 """
282
283 def __init__(self, max_life=datetime.timedelta(minutes=2), io_loop=None):
284 self._max_life = max_life
285 if io_loop is None:
286 io_loop = IOLoop.current()
287 self._io_loop = io_loop
288 self._data = {}
289
290 def token_requested(self, data):
291 """Does data represent a token creation request? True or False."""
292 return (
293 'RequestId' in data and
294 data.get('Type', None) == 'GUIToken' and
295 data.get('Request', None) == 'Create'
296 )
297
298 def process_token_request(self, data, user, write_message):
299 """Create a single-use, time-expired token and send it back."""
300 token = uuid.uuid4().hex
301
302 def expire_token():
303 self._data.pop(token, None)
304 handle = self._io_loop.add_timeout(self._max_life, expire_token)
305 now = datetime.datetime.utcnow()
306 # Stashing these is a security risk. We currently deem this risk to
307 # be acceptably small. Even keeping an authenticated websocket in
308 # memory seems to be of a similar risk profile, and we cannot operate
309 # without that.
310 self._data[token] = dict(
311 username=user.username,
312 password=user.password,
313 handle=handle
314 )
315 write_message({
316 'RequestId': data['RequestId'],
317 'Response': {
318 'Token': token,
319 'Created': now.isoformat() + 'Z',
320 'Expires': (now + self._max_life).isoformat() + 'Z'
321 }
322 })
323
324 def authentication_requested(self, data):
325 """Does data represent a token authentication request? True or False.
326 """
327 params = data.get('Params', {})
328 return (
329 'RequestId' in data and
330 data.get('Type') == 'GUIToken' and
331 data.get('Request') == 'Login' and
332 'Token' in params
333 )
334
335 def process_authentication_request(self, data, write_message):
336 """Get the credentials for the token, or send an error."""
337 credentials = self._data.pop(data['Params']['Token'], None)
338 if credentials is not None:
339 self._io_loop.remove_timeout(credentials['handle'])
340 return credentials['username'], credentials['password']
341 else:
342 write_message({
343 'RequestId': data['RequestId'],
344 'Error': 'unknown, fulfilled, or expired token',
345 'ErrorCode': 'unauthorized access',
346 'Response': {},
347 })
348 # None is an explicit return marker to say "I handled this".
349 # It is returned by default.
350
351 def process_authentication_response(self, data, user):
352 """Make a successful token authentication response.
353
354 This includes the username and password so that clients can then use
355 them. For instance, the GUI stashes them in session storage so that
356 reloading the page does not require logging in again."""
357 return {
358 'RequestId': data['RequestId'],
359 'Response': {'AuthTag': user.username, 'Password': user.password}
360 }
222361
=== modified file 'server/guiserver/tests/test_auth.py'
--- server/guiserver/tests/test_auth.py 2013-08-07 16:34:09 +0000
+++ server/guiserver/tests/test_auth.py 2013-11-23 02:10:22 +0000
@@ -16,8 +16,10 @@
1616
17"""Tests for the Juju GUI server authentication management."""17"""Tests for the Juju GUI server authentication management."""
1818
19import datetime
19import unittest20import unittest
2021
22import mock
21from tornado.testing import LogTrapTestCase23from tornado.testing import LogTrapTestCase
2224
23from guiserver import auth25from guiserver import auth
@@ -244,3 +246,164 @@
244 for request in requests:246 for request in requests:
245 is_login = self.backend.request_is_login(request)247 is_login = self.backend.request_is_login(request)
246 self.assertFalse(is_login, request)248 self.assertFalse(is_login, request)
249
250
251class TestAuthenticationTokenHandler(unittest.TestCase):
252
253 def setUp(self):
254 super(TestAuthenticationTokenHandler, self).setUp()
255 self.io_loop = mock.Mock()
256 self.max_life = datetime.timedelta(minutes=1)
257 self.tokens = auth.AuthenticationTokenHandler(
258 self.max_life, self.io_loop)
259
260 def test_explicit_initialization(self):
261 # The class accepted the explicit initialization.
262 self.assertEqual(self.max_life, self.tokens._max_life)
263 self.assertEqual(self.io_loop, self.tokens._io_loop)
264 self.assertEqual({}, self.tokens._data)
265
266 @mock.patch('tornado.ioloop.IOLoop.current',
267 mock.Mock(return_value='mockloop'))
268 def test_default_initialization(self):
269 # The class has sane initialization defaults.
270 tokens = auth.AuthenticationTokenHandler()
271 self.assertEqual(
272 datetime.timedelta(minutes=2), tokens._max_life)
273 self.assertEqual('mockloop', tokens._io_loop)
274
275 def test_token_requested(self):
276 # It recognizes a token request.
277 requests = (
278 dict(RequestId=42, Type='GUIToken', Request='Create'),
279 dict(RequestId=22, Type='GUIToken', Request='Create', Params={}))
280 for request in requests:
281 is_token_requested = self.tokens.token_requested(request)
282 self.assertTrue(is_token_requested, request)
283
284 def test_not_token_requested(self):
285 # It rejects invalid token requests.
286 requests = (
287 dict(),
288 dict(Type='GUIToken', Request='Create'),
289 dict(RequestId=42, Request='Create'),
290 dict(RequestId=42, Type='GUIToken'))
291 for request in requests:
292 token_requested = self.tokens.token_requested(request)
293 self.assertFalse(token_requested, request)
294
295 @mock.patch('uuid.uuid4', mock.Mock(return_value=mock.Mock(hex='DEFACED')))
296 @mock.patch('datetime.datetime',
297 mock.Mock(
298 **{'utcnow.return_value':
299 datetime.datetime(2013, 11, 21, 21)}))
300 def test_process_token_request(self):
301 # It correctly responds to token requests.
302 user = auth.User('user-admin', 'ADMINSECRET')
303 write_message = mock.Mock()
304 data = dict(RequestId=42, Type='GUIToken', Request='Create')
305 self.tokens.process_token_request(data, user, write_message)
306 write_message.assert_called_once_with(dict(
307 RequestId=42,
308 Response=dict(
309 Token='DEFACED',
310 Created='2013-11-21T21:00:00Z',
311 Expires='2013-11-21T21:01:00Z'
312 )
313 ))
314 self.assertTrue('DEFACED' in self.tokens._data)
315 self.assertEqual(
316 {'username', 'password', 'handle'},
317 set(self.tokens._data['DEFACED'].keys()))
318 self.assertEqual(
319 user.username, self.tokens._data['DEFACED']['username'])
320 self.assertEqual(
321 user.password, self.tokens._data['DEFACED']['password'])
322 self.assertEqual(
323 self.max_life, self.io_loop.add_timeout.call_args[0][0])
324 self.assertTrue('DEFACED' in self.tokens._data)
325 expire_token = self.io_loop.add_timeout.call_args[0][1]
326 expire_token()
327 self.assertFalse('DEFACED' in self.tokens._data)
328
329 def test_authentication_requested(self):
330 # It recognizes an authentication request.
331 request = dict(
332 RequestId=42, Type='GUIToken', Request='Login',
333 Params={'Token': 'DEFACED'})
334 auth_requested = self.tokens.authentication_requested(request)
335 self.assertTrue(auth_requested, request)
336
337 def test_not_authentication_requested(self):
338 # It rejects invalid authentication requests.
339 requests = (
340 dict(),
341 dict(Type='GUIToken', Request='Login', Params={'Token': 'T'}),
342 dict(RequestId=42, Request='Login', Params={'Token': 'DEFACED'}),
343 dict(RequestId=42, Type='GUIToken', Params={'Token': 'DEFACED'}),
344 dict(RequestId=42, Type='GUIToken', Request='Login'),
345 dict(RequestId=42, Type='GUIToken', Request='Login', Params={}))
346 for request in requests:
347 auth_requested = self.tokens.authentication_requested(request)
348 self.assertFalse(auth_requested, request)
349
350 def test_known_authentication_request(self):
351 # It correctly responds to authentication requests with known tokens.
352 username = 'user-admin'
353 password = 'ADMINSECRET'
354 self.tokens._data['DEFACED'] = dict(
355 handle='handle marker', username=username, password=password)
356 request = dict(
357 RequestId=42, Type='GUIToken', Request='Login',
358 Params={'Token': 'DEFACED'})
359 write_message = mock.Mock()
360 self.assertEqual(
361 (username, password),
362 self.tokens.process_authentication_request(request, write_message))
363 self.io_loop.remove_timeout.assert_called_once_with('handle marker')
364 self.assertFalse(write_message.called)
365 self.assertFalse('DEFACED' in self.tokens._data)
366
367 def test_unknown_authentication_request(self):
368 # It correctly rejects authentication requests with unknown tokens.
369 request = dict(
370 RequestId=42, Type='GUIToken', Request='Login',
371 Params={'Token': 'DEFACED'})
372 write_message = mock.Mock()
373 self.assertEqual(
374 None,
375 self.tokens.process_authentication_request(request, write_message))
376 self.assertFalse(self.io_loop.remove_timeout.called)
377 write_message.assert_called_once_with(dict(
378 RequestId=42,
379 Error='unknown, fulfilled, or expired token',
380 ErrorCode='unauthorized access',
381 Response={}))
382
383 @mock.patch('uuid.uuid4', mock.Mock(return_value=mock.Mock(hex='DEFACED')))
384 @mock.patch('datetime.datetime',
385 mock.Mock(
386 **{'utcnow.return_value':
387 datetime.datetime(2013, 11, 21, 21)}))
388 def test_token_request_and_authentication_collaborate(self):
389 # process_token_request and process_authentication_request collaborate.
390 # This is a small integration test of the two functions' interaction.
391 user = auth.User('user-admin', 'ADMINSECRET')
392 write_message = mock.Mock()
393 request = dict(RequestId=42, Type='GUIToken', Request='Create')
394 self.tokens.process_token_request(request, user, write_message)
395 request = dict(
396 RequestId=43, Type='GUIToken', Request='Login',
397 Params={'Token': 'DEFACED'})
398 self.assertEqual(
399 (user.username, user.password),
400 self.tokens.process_authentication_request(request, write_message))
401
402 def test_process_authentication_response(self):
403 # It translates a normal authentication success.
404 user = auth.User('user-admin', 'ADMINSECRET')
405 response = {'RequestId': 42, 'Response': {}}
406 self.assertEqual(
407 dict(RequestId=42,
408 Response=dict(AuthTag=user.username, Password=user.password)),
409 self.tokens.process_authentication_response(response, user))

Subscribers

People subscribed via source and target branches

to all changes: