Merge lp:~gary/charms/precise/juju-gui/authtoken1 into lp:~juju-gui/charms/precise/juju-gui/trunk
- Precise Pangolin (12.04)
- authtoken1
- Merge into trunk
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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
charmers | Pending | ||
Review via email: mp+196347@code.launchpad.net |
Commit message
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.
Gary Poster (gary) wrote : | # |
Francesco Banconi (frankban) wrote : | # |
Nice branch Gary. Tests pass and are very nice.
LGTM with only minors/optional changes.
Thank you!
https:/
File server/
https:/
server/
response. Lifetime is in seconds.
Does "Lifetime is in seconds" still apply?
https:/
server/
request? True or False."""
Does data represent a token authentication request?
https:/
File server/
https:/
server/
self.io_
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:/
server/
self.io_
assert_
https:/
server/
test of the two functions' interaction.
Very nice!
- 139. By Gary Poster
-
respond to review
- 140. By Gary Poster
-
correct grammar
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:/
Gary Poster (gary) wrote : | # |
Thank you for the very helpful review, Francesco, and for the elegant
pre-imp ideas that guided this branch.
Preview Diff
1 | === modified file 'server/guiserver/auth.py' | |||
2 | --- server/guiserver/auth.py 2013-10-17 14:12:16 +0000 | |||
3 | +++ server/guiserver/auth.py 2013-11-23 02:10:22 +0000 | |||
4 | @@ -36,7 +36,11 @@ | |||
5 | 36 | if the authentication succeeds. | 36 | if the authentication succeeds. |
6 | 37 | """ | 37 | """ |
7 | 38 | 38 | ||
8 | 39 | import datetime | ||
9 | 39 | import logging | 40 | import logging |
10 | 41 | import uuid | ||
11 | 42 | |||
12 | 43 | from tornado.ioloop import IOLoop | ||
13 | 40 | 44 | ||
14 | 41 | 45 | ||
15 | 42 | class User(object): | 46 | class User(object): |
16 | @@ -44,10 +48,6 @@ | |||
17 | 44 | 48 | ||
18 | 45 | def __init__(self, username='', password='', is_authenticated=False): | 49 | def __init__(self, username='', password='', is_authenticated=False): |
19 | 46 | self.is_authenticated = is_authenticated | 50 | self.is_authenticated = is_authenticated |
20 | 47 | # XXX (frankban) YAGNI: the username/password attributes are not | ||
21 | 48 | # required for now, but they will help handling the HA story, i.e. in | ||
22 | 49 | # the process of re-authenticating to the API after switching from one | ||
23 | 50 | # Juju state/API server to another. | ||
24 | 51 | self.username = username | 51 | self.username = username |
25 | 52 | self.password = password | 52 | self.password = password |
26 | 53 | 53 | ||
27 | @@ -219,3 +219,142 @@ | |||
28 | 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.""" |
29 | 220 | backend_class = {'go': GoBackend, 'python': PythonBackend}[apiversion] | 220 | backend_class = {'go': GoBackend, 'python': PythonBackend}[apiversion] |
30 | 221 | return backend_class() | 221 | return backend_class() |
31 | 222 | |||
32 | 223 | |||
33 | 224 | class AuthenticationTokenHandler(object): | ||
34 | 225 | """Handle requests related to authentication tokens. | ||
35 | 226 | |||
36 | 227 | A token creation request looks like the following: | ||
37 | 228 | |||
38 | 229 | { | ||
39 | 230 | 'RequestId': 42, | ||
40 | 231 | 'Type': 'GUIToken', | ||
41 | 232 | 'Request': 'Create', | ||
42 | 233 | 'Params': {}, | ||
43 | 234 | } | ||
44 | 235 | |||
45 | 236 | Here is an example of a token creation response. | ||
46 | 237 | |||
47 | 238 | { | ||
48 | 239 | 'RequestId': 42, | ||
49 | 240 | 'Response': { | ||
50 | 241 | 'Token': 'TOKEN-STRING', | ||
51 | 242 | 'Created': '2013-11-21T12:34:46.778866Z', | ||
52 | 243 | 'Expires': '2013-11-21T12:36:46.778866Z' | ||
53 | 244 | } | ||
54 | 245 | } | ||
55 | 246 | |||
56 | 247 | A token authentication request looks like the following: | ||
57 | 248 | |||
58 | 249 | { | ||
59 | 250 | 'RequestId': 42, | ||
60 | 251 | 'Type': 'GUIToken', | ||
61 | 252 | 'Request': 'Login', | ||
62 | 253 | 'Params': {'Token': 'TOKEN-STRING'}, | ||
63 | 254 | } | ||
64 | 255 | |||
65 | 256 | Here is an example of a successful login response: | ||
66 | 257 | |||
67 | 258 | { | ||
68 | 259 | 'RequestId': 42, | ||
69 | 260 | 'Response': {'AuthTag': 'user-admin', 'Password': 'ADMIN-SECRET'} | ||
70 | 261 | } | ||
71 | 262 | |||
72 | 263 | A login failure response is like the following: | ||
73 | 264 | |||
74 | 265 | { | ||
75 | 266 | 'RequestId': 42, | ||
76 | 267 | 'Error': 'unknown, fulfilled, or expired token', | ||
77 | 268 | 'ErrorCode': 'unauthorized access', | ||
78 | 269 | 'Response': {}, | ||
79 | 270 | } | ||
80 | 271 | |||
81 | 272 | Juju itself might return a failure response like the following, but this | ||
82 | 273 | would be difficult or impossible to trigger as of this writing: | ||
83 | 274 | |||
84 | 275 | { | ||
85 | 276 | 'RequestId': 42, | ||
86 | 277 | 'Error': 'invalid entity name or password', | ||
87 | 278 | 'ErrorCode': 'unauthorized access', | ||
88 | 279 | 'Response': {}, | ||
89 | 280 | } | ||
90 | 281 | """ | ||
91 | 282 | |||
92 | 283 | def __init__(self, max_life=datetime.timedelta(minutes=2), io_loop=None): | ||
93 | 284 | self._max_life = max_life | ||
94 | 285 | if io_loop is None: | ||
95 | 286 | io_loop = IOLoop.current() | ||
96 | 287 | self._io_loop = io_loop | ||
97 | 288 | self._data = {} | ||
98 | 289 | |||
99 | 290 | def token_requested(self, data): | ||
100 | 291 | """Does data represent a token creation request? True or False.""" | ||
101 | 292 | return ( | ||
102 | 293 | 'RequestId' in data and | ||
103 | 294 | data.get('Type', None) == 'GUIToken' and | ||
104 | 295 | data.get('Request', None) == 'Create' | ||
105 | 296 | ) | ||
106 | 297 | |||
107 | 298 | def process_token_request(self, data, user, write_message): | ||
108 | 299 | """Create a single-use, time-expired token and send it back.""" | ||
109 | 300 | token = uuid.uuid4().hex | ||
110 | 301 | |||
111 | 302 | def expire_token(): | ||
112 | 303 | self._data.pop(token, None) | ||
113 | 304 | handle = self._io_loop.add_timeout(self._max_life, expire_token) | ||
114 | 305 | now = datetime.datetime.utcnow() | ||
115 | 306 | # Stashing these is a security risk. We currently deem this risk to | ||
116 | 307 | # be acceptably small. Even keeping an authenticated websocket in | ||
117 | 308 | # memory seems to be of a similar risk profile, and we cannot operate | ||
118 | 309 | # without that. | ||
119 | 310 | self._data[token] = dict( | ||
120 | 311 | username=user.username, | ||
121 | 312 | password=user.password, | ||
122 | 313 | handle=handle | ||
123 | 314 | ) | ||
124 | 315 | write_message({ | ||
125 | 316 | 'RequestId': data['RequestId'], | ||
126 | 317 | 'Response': { | ||
127 | 318 | 'Token': token, | ||
128 | 319 | 'Created': now.isoformat() + 'Z', | ||
129 | 320 | 'Expires': (now + self._max_life).isoformat() + 'Z' | ||
130 | 321 | } | ||
131 | 322 | }) | ||
132 | 323 | |||
133 | 324 | def authentication_requested(self, data): | ||
134 | 325 | """Does data represent a token authentication request? True or False. | ||
135 | 326 | """ | ||
136 | 327 | params = data.get('Params', {}) | ||
137 | 328 | return ( | ||
138 | 329 | 'RequestId' in data and | ||
139 | 330 | data.get('Type') == 'GUIToken' and | ||
140 | 331 | data.get('Request') == 'Login' and | ||
141 | 332 | 'Token' in params | ||
142 | 333 | ) | ||
143 | 334 | |||
144 | 335 | def process_authentication_request(self, data, write_message): | ||
145 | 336 | """Get the credentials for the token, or send an error.""" | ||
146 | 337 | credentials = self._data.pop(data['Params']['Token'], None) | ||
147 | 338 | if credentials is not None: | ||
148 | 339 | self._io_loop.remove_timeout(credentials['handle']) | ||
149 | 340 | return credentials['username'], credentials['password'] | ||
150 | 341 | else: | ||
151 | 342 | write_message({ | ||
152 | 343 | 'RequestId': data['RequestId'], | ||
153 | 344 | 'Error': 'unknown, fulfilled, or expired token', | ||
154 | 345 | 'ErrorCode': 'unauthorized access', | ||
155 | 346 | 'Response': {}, | ||
156 | 347 | }) | ||
157 | 348 | # None is an explicit return marker to say "I handled this". | ||
158 | 349 | # It is returned by default. | ||
159 | 350 | |||
160 | 351 | def process_authentication_response(self, data, user): | ||
161 | 352 | """Make a successful token authentication response. | ||
162 | 353 | |||
163 | 354 | This includes the username and password so that clients can then use | ||
164 | 355 | them. For instance, the GUI stashes them in session storage so that | ||
165 | 356 | reloading the page does not require logging in again.""" | ||
166 | 357 | return { | ||
167 | 358 | 'RequestId': data['RequestId'], | ||
168 | 359 | 'Response': {'AuthTag': user.username, 'Password': user.password} | ||
169 | 360 | } | ||
170 | 222 | 361 | ||
171 | === modified file 'server/guiserver/tests/test_auth.py' | |||
172 | --- server/guiserver/tests/test_auth.py 2013-08-07 16:34:09 +0000 | |||
173 | +++ server/guiserver/tests/test_auth.py 2013-11-23 02:10:22 +0000 | |||
174 | @@ -16,8 +16,10 @@ | |||
175 | 16 | 16 | ||
176 | 17 | """Tests for the Juju GUI server authentication management.""" | 17 | """Tests for the Juju GUI server authentication management.""" |
177 | 18 | 18 | ||
178 | 19 | import datetime | ||
179 | 19 | import unittest | 20 | import unittest |
180 | 20 | 21 | ||
181 | 22 | import mock | ||
182 | 21 | from tornado.testing import LogTrapTestCase | 23 | from tornado.testing import LogTrapTestCase |
183 | 22 | 24 | ||
184 | 23 | from guiserver import auth | 25 | from guiserver import auth |
185 | @@ -244,3 +246,164 @@ | |||
186 | 244 | for request in requests: | 246 | for request in requests: |
187 | 245 | is_login = self.backend.request_is_login(request) | 247 | is_login = self.backend.request_is_login(request) |
188 | 246 | self.assertFalse(is_login, request) | 248 | self.assertFalse(is_login, request) |
189 | 249 | |||
190 | 250 | |||
191 | 251 | class TestAuthenticationTokenHandler(unittest.TestCase): | ||
192 | 252 | |||
193 | 253 | def setUp(self): | ||
194 | 254 | super(TestAuthenticationTokenHandler, self).setUp() | ||
195 | 255 | self.io_loop = mock.Mock() | ||
196 | 256 | self.max_life = datetime.timedelta(minutes=1) | ||
197 | 257 | self.tokens = auth.AuthenticationTokenHandler( | ||
198 | 258 | self.max_life, self.io_loop) | ||
199 | 259 | |||
200 | 260 | def test_explicit_initialization(self): | ||
201 | 261 | # The class accepted the explicit initialization. | ||
202 | 262 | self.assertEqual(self.max_life, self.tokens._max_life) | ||
203 | 263 | self.assertEqual(self.io_loop, self.tokens._io_loop) | ||
204 | 264 | self.assertEqual({}, self.tokens._data) | ||
205 | 265 | |||
206 | 266 | @mock.patch('tornado.ioloop.IOLoop.current', | ||
207 | 267 | mock.Mock(return_value='mockloop')) | ||
208 | 268 | def test_default_initialization(self): | ||
209 | 269 | # The class has sane initialization defaults. | ||
210 | 270 | tokens = auth.AuthenticationTokenHandler() | ||
211 | 271 | self.assertEqual( | ||
212 | 272 | datetime.timedelta(minutes=2), tokens._max_life) | ||
213 | 273 | self.assertEqual('mockloop', tokens._io_loop) | ||
214 | 274 | |||
215 | 275 | def test_token_requested(self): | ||
216 | 276 | # It recognizes a token request. | ||
217 | 277 | requests = ( | ||
218 | 278 | dict(RequestId=42, Type='GUIToken', Request='Create'), | ||
219 | 279 | dict(RequestId=22, Type='GUIToken', Request='Create', Params={})) | ||
220 | 280 | for request in requests: | ||
221 | 281 | is_token_requested = self.tokens.token_requested(request) | ||
222 | 282 | self.assertTrue(is_token_requested, request) | ||
223 | 283 | |||
224 | 284 | def test_not_token_requested(self): | ||
225 | 285 | # It rejects invalid token requests. | ||
226 | 286 | requests = ( | ||
227 | 287 | dict(), | ||
228 | 288 | dict(Type='GUIToken', Request='Create'), | ||
229 | 289 | dict(RequestId=42, Request='Create'), | ||
230 | 290 | dict(RequestId=42, Type='GUIToken')) | ||
231 | 291 | for request in requests: | ||
232 | 292 | token_requested = self.tokens.token_requested(request) | ||
233 | 293 | self.assertFalse(token_requested, request) | ||
234 | 294 | |||
235 | 295 | @mock.patch('uuid.uuid4', mock.Mock(return_value=mock.Mock(hex='DEFACED'))) | ||
236 | 296 | @mock.patch('datetime.datetime', | ||
237 | 297 | mock.Mock( | ||
238 | 298 | **{'utcnow.return_value': | ||
239 | 299 | datetime.datetime(2013, 11, 21, 21)})) | ||
240 | 300 | def test_process_token_request(self): | ||
241 | 301 | # It correctly responds to token requests. | ||
242 | 302 | user = auth.User('user-admin', 'ADMINSECRET') | ||
243 | 303 | write_message = mock.Mock() | ||
244 | 304 | data = dict(RequestId=42, Type='GUIToken', Request='Create') | ||
245 | 305 | self.tokens.process_token_request(data, user, write_message) | ||
246 | 306 | write_message.assert_called_once_with(dict( | ||
247 | 307 | RequestId=42, | ||
248 | 308 | Response=dict( | ||
249 | 309 | Token='DEFACED', | ||
250 | 310 | Created='2013-11-21T21:00:00Z', | ||
251 | 311 | Expires='2013-11-21T21:01:00Z' | ||
252 | 312 | ) | ||
253 | 313 | )) | ||
254 | 314 | self.assertTrue('DEFACED' in self.tokens._data) | ||
255 | 315 | self.assertEqual( | ||
256 | 316 | {'username', 'password', 'handle'}, | ||
257 | 317 | set(self.tokens._data['DEFACED'].keys())) | ||
258 | 318 | self.assertEqual( | ||
259 | 319 | user.username, self.tokens._data['DEFACED']['username']) | ||
260 | 320 | self.assertEqual( | ||
261 | 321 | user.password, self.tokens._data['DEFACED']['password']) | ||
262 | 322 | self.assertEqual( | ||
263 | 323 | self.max_life, self.io_loop.add_timeout.call_args[0][0]) | ||
264 | 324 | self.assertTrue('DEFACED' in self.tokens._data) | ||
265 | 325 | expire_token = self.io_loop.add_timeout.call_args[0][1] | ||
266 | 326 | expire_token() | ||
267 | 327 | self.assertFalse('DEFACED' in self.tokens._data) | ||
268 | 328 | |||
269 | 329 | def test_authentication_requested(self): | ||
270 | 330 | # It recognizes an authentication request. | ||
271 | 331 | request = dict( | ||
272 | 332 | RequestId=42, Type='GUIToken', Request='Login', | ||
273 | 333 | Params={'Token': 'DEFACED'}) | ||
274 | 334 | auth_requested = self.tokens.authentication_requested(request) | ||
275 | 335 | self.assertTrue(auth_requested, request) | ||
276 | 336 | |||
277 | 337 | def test_not_authentication_requested(self): | ||
278 | 338 | # It rejects invalid authentication requests. | ||
279 | 339 | requests = ( | ||
280 | 340 | dict(), | ||
281 | 341 | dict(Type='GUIToken', Request='Login', Params={'Token': 'T'}), | ||
282 | 342 | dict(RequestId=42, Request='Login', Params={'Token': 'DEFACED'}), | ||
283 | 343 | dict(RequestId=42, Type='GUIToken', Params={'Token': 'DEFACED'}), | ||
284 | 344 | dict(RequestId=42, Type='GUIToken', Request='Login'), | ||
285 | 345 | dict(RequestId=42, Type='GUIToken', Request='Login', Params={})) | ||
286 | 346 | for request in requests: | ||
287 | 347 | auth_requested = self.tokens.authentication_requested(request) | ||
288 | 348 | self.assertFalse(auth_requested, request) | ||
289 | 349 | |||
290 | 350 | def test_known_authentication_request(self): | ||
291 | 351 | # It correctly responds to authentication requests with known tokens. | ||
292 | 352 | username = 'user-admin' | ||
293 | 353 | password = 'ADMINSECRET' | ||
294 | 354 | self.tokens._data['DEFACED'] = dict( | ||
295 | 355 | handle='handle marker', username=username, password=password) | ||
296 | 356 | request = dict( | ||
297 | 357 | RequestId=42, Type='GUIToken', Request='Login', | ||
298 | 358 | Params={'Token': 'DEFACED'}) | ||
299 | 359 | write_message = mock.Mock() | ||
300 | 360 | self.assertEqual( | ||
301 | 361 | (username, password), | ||
302 | 362 | self.tokens.process_authentication_request(request, write_message)) | ||
303 | 363 | self.io_loop.remove_timeout.assert_called_once_with('handle marker') | ||
304 | 364 | self.assertFalse(write_message.called) | ||
305 | 365 | self.assertFalse('DEFACED' in self.tokens._data) | ||
306 | 366 | |||
307 | 367 | def test_unknown_authentication_request(self): | ||
308 | 368 | # It correctly rejects authentication requests with unknown tokens. | ||
309 | 369 | request = dict( | ||
310 | 370 | RequestId=42, Type='GUIToken', Request='Login', | ||
311 | 371 | Params={'Token': 'DEFACED'}) | ||
312 | 372 | write_message = mock.Mock() | ||
313 | 373 | self.assertEqual( | ||
314 | 374 | None, | ||
315 | 375 | self.tokens.process_authentication_request(request, write_message)) | ||
316 | 376 | self.assertFalse(self.io_loop.remove_timeout.called) | ||
317 | 377 | write_message.assert_called_once_with(dict( | ||
318 | 378 | RequestId=42, | ||
319 | 379 | Error='unknown, fulfilled, or expired token', | ||
320 | 380 | ErrorCode='unauthorized access', | ||
321 | 381 | Response={})) | ||
322 | 382 | |||
323 | 383 | @mock.patch('uuid.uuid4', mock.Mock(return_value=mock.Mock(hex='DEFACED'))) | ||
324 | 384 | @mock.patch('datetime.datetime', | ||
325 | 385 | mock.Mock( | ||
326 | 386 | **{'utcnow.return_value': | ||
327 | 387 | datetime.datetime(2013, 11, 21, 21)})) | ||
328 | 388 | def test_token_request_and_authentication_collaborate(self): | ||
329 | 389 | # process_token_request and process_authentication_request collaborate. | ||
330 | 390 | # This is a small integration test of the two functions' interaction. | ||
331 | 391 | user = auth.User('user-admin', 'ADMINSECRET') | ||
332 | 392 | write_message = mock.Mock() | ||
333 | 393 | request = dict(RequestId=42, Type='GUIToken', Request='Create') | ||
334 | 394 | self.tokens.process_token_request(request, user, write_message) | ||
335 | 395 | request = dict( | ||
336 | 396 | RequestId=43, Type='GUIToken', Request='Login', | ||
337 | 397 | Params={'Token': 'DEFACED'}) | ||
338 | 398 | self.assertEqual( | ||
339 | 399 | (user.username, user.password), | ||
340 | 400 | self.tokens.process_authentication_request(request, write_message)) | ||
341 | 401 | |||
342 | 402 | def test_process_authentication_response(self): | ||
343 | 403 | # It translates a normal authentication success. | ||
344 | 404 | user = auth.User('user-admin', 'ADMINSECRET') | ||
345 | 405 | response = {'RequestId': 42, 'Response': {}} | ||
346 | 406 | self.assertEqual( | ||
347 | 407 | dict(RequestId=42, | ||
348 | 408 | Response=dict(AuthTag=user.username, Password=user.password)), | ||
349 | 409 | self.tokens.process_authentication_response(response, user)) |
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): guiserver/ auth.py guiserver/ tests/test_ auth.py
A [revision details]
M server/
M server/