Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(999)

Side by Side Diff: third_party/google-endpoints/endpoints/users_id_token.py

Issue 2666783008: Add google-endpoints to third_party/. (Closed)
Patch Set: Created 3 years, 10 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
OLDNEW
(Empty)
1 # Copyright 2016 Google Inc. All Rights Reserved.
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 # http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14
15 """Utility library for reading user information from an id_token.
16
17 This is an experimental library that can temporarily be used to extract
18 a user from an id_token. The functionality provided by this library
19 will be provided elsewhere in the future.
20 """
21
22 import base64
23 import json
24 import logging
25 import os
26 import re
27 import time
28 import urllib
29
30 from google.appengine.api import memcache
31 from google.appengine.api import oauth
32 from google.appengine.api import urlfetch
33 from google.appengine.api import users
34
35 try:
36 # PyCrypto may not be installed for the import_aeta_test or in dev's
37 # individual Python installations. It is available on AppEngine in prod.
38
39 # Disable "Import not at top of file" warning.
40 # pylint: disable=g-import-not-at-top
41 from Crypto.Hash import SHA256
42 from Crypto.PublicKey import RSA
43 # pylint: enable=g-import-not-at-top
44 _CRYPTO_LOADED = True
45 except ImportError:
46 _CRYPTO_LOADED = False
47
48
49 __all__ = ['get_current_user',
50 'InvalidGetUserCall',
51 'SKIP_CLIENT_ID_CHECK']
52
53 SKIP_CLIENT_ID_CHECK = ['*'] # This needs to be a list, for comparisons.
54 _CLOCK_SKEW_SECS = 300 # 5 minutes in seconds
55 _MAX_TOKEN_LIFETIME_SECS = 86400 # 1 day in seconds
56 _DEFAULT_CERT_URI = ('https://www.googleapis.com/service_accounts/v1/metadata/'
57 'raw/federated-signon@system.gserviceaccount.com')
58 _ENV_USE_OAUTH_SCOPE = 'ENDPOINTS_USE_OAUTH_SCOPE'
59 _ENV_AUTH_EMAIL = 'ENDPOINTS_AUTH_EMAIL'
60 _ENV_AUTH_DOMAIN = 'ENDPOINTS_AUTH_DOMAIN'
61 _EMAIL_SCOPE = 'https://www.googleapis.com/auth/userinfo.email'
62 _TOKENINFO_URL = 'https://www.googleapis.com/oauth2/v1/tokeninfo'
63 _MAX_AGE_REGEX = re.compile(r'\s*max-age\s*=\s*(\d+)\s*')
64 _CERT_NAMESPACE = '__verify_jwt'
65 _ISSUERS = ('accounts.google.com', 'https://accounts.google.com')
66
67
68 class _AppIdentityError(Exception):
69 pass
70
71
72 class InvalidGetUserCall(Exception):
73 """Called get_current_user when the environment was not set up for it."""
74
75
76 # pylint: disable=g-bad-name
77 def get_current_user():
78 """Get user information from the id_token or oauth token in the request.
79
80 This should only be called from within an Endpoints request handler,
81 decorated with an @endpoints.method decorator. The decorator should include
82 the https://www.googleapis.com/auth/userinfo.email scope.
83
84 If the current request uses an id_token, this validates and parses the token
85 against the info in the current request handler and returns the user.
86 Or, for an Oauth token, this call validates the token against the tokeninfo
87 endpoint and oauth.get_current_user with the scopes provided in the method's
88 decorator.
89
90 Returns:
91 None if there is no token or it's invalid. If the token was valid, this
92 returns a User. Only the user's email field is guaranteed to be set.
93 Other fields may be empty.
94
95 Raises:
96 InvalidGetUserCall: if the environment variables necessary to determine the
97 endpoints user are not set. These are typically set when processing a
98 request using an Endpoints handler. If they are not set, it likely
99 indicates that this function was called from outside an Endpoints request
100 handler.
101 """
102 if not _is_auth_info_available():
103 raise InvalidGetUserCall('No valid endpoints user in environment.')
104
105 if _ENV_USE_OAUTH_SCOPE in os.environ:
106 # We can get more information from the oauth.get_current_user function,
107 # as long as we know what scope to use. Since that scope has been
108 # cached, we can just return this:
109 return oauth.get_current_user(os.environ[_ENV_USE_OAUTH_SCOPE])
110
111 if (_ENV_AUTH_EMAIL in os.environ and
112 _ENV_AUTH_DOMAIN in os.environ):
113 if not os.environ[_ENV_AUTH_EMAIL]:
114 # Either there was no id token or we were unable to validate it,
115 # so there's no user.
116 return None
117
118 return users.User(os.environ[_ENV_AUTH_EMAIL],
119 os.environ[_ENV_AUTH_DOMAIN] or None)
120
121 # Shouldn't hit this, because all the _is_auth_info_available cases were
122 # checked, but just in case.
123 return None
124
125
126 # pylint: disable=g-bad-name
127 def _is_auth_info_available():
128 """Check if user auth info has been set in environment variables."""
129 return ((_ENV_AUTH_EMAIL in os.environ and
130 _ENV_AUTH_DOMAIN in os.environ) or
131 _ENV_USE_OAUTH_SCOPE in os.environ)
132
133
134 def _maybe_set_current_user_vars(method, api_info=None, request=None):
135 """Get user information from the id_token or oauth token in the request.
136
137 Used internally by Endpoints to set up environment variables for user
138 authentication.
139
140 Args:
141 method: The class method that's handling this request. This method
142 should be annotated with @endpoints.method.
143 api_info: An api_config._ApiInfo instance. Optional. If None, will attempt
144 to parse api_info from the implicit instance of the method.
145 request: The current request, or None.
146 """
147 if _is_auth_info_available():
148 return
149
150 # By default, there's no user.
151 os.environ[_ENV_AUTH_EMAIL] = ''
152 os.environ[_ENV_AUTH_DOMAIN] = ''
153
154 # Choose settings on the method, if specified. Otherwise, choose settings
155 # from the API. Specifically check for None, so that methods can override
156 # with empty lists.
157 try:
158 api_info = api_info or method.im_self.api_info
159 except AttributeError:
160 # The most common case for this is someone passing an unbound method
161 # to this function, which most likely only happens in our unit tests.
162 # We could propagate the exception, but this results in some really
163 # difficult to debug behavior. Better to log a warning and pretend
164 # there are no API-level settings.
165 logging.warning('AttributeError when accessing %s.im_self. An unbound '
166 'method was probably passed as an endpoints handler.',
167 method.__name__)
168 scopes = method.method_info.scopes
169 audiences = method.method_info.audiences
170 allowed_client_ids = method.method_info.allowed_client_ids
171 else:
172 scopes = (method.method_info.scopes
173 if method.method_info.scopes is not None
174 else api_info.scopes)
175 audiences = (method.method_info.audiences
176 if method.method_info.audiences is not None
177 else api_info.audiences)
178 allowed_client_ids = (method.method_info.allowed_client_ids
179 if method.method_info.allowed_client_ids is not None
180 else api_info.allowed_client_ids)
181
182 if not scopes and not audiences and not allowed_client_ids:
183 # The user hasn't provided any information to allow us to parse either
184 # an id_token or an Oauth token. They appear not to be interested in
185 # auth.
186 return
187
188 token = _get_token(request)
189 if not token:
190 return None
191
192 # When every item in the acceptable scopes list is
193 # "https://www.googleapis.com/auth/userinfo.email", and there is a non-empty
194 # allowed_client_ids list, the API code will first attempt OAuth 2/OpenID
195 # Connect ID token processing for any incoming bearer token.
196 if ((scopes == [_EMAIL_SCOPE] or scopes == (_EMAIL_SCOPE,)) and
197 allowed_client_ids):
198 logging.debug('Checking for id_token.')
199 time_now = long(time.time())
200 user = _get_id_token_user(token, audiences, allowed_client_ids, time_now,
201 memcache)
202 if user:
203 os.environ[_ENV_AUTH_EMAIL] = user.email()
204 os.environ[_ENV_AUTH_DOMAIN] = user.auth_domain()
205 return
206
207 # Check if the user is interested in an oauth token.
208 if scopes:
209 logging.debug('Checking for oauth token.')
210 if _is_local_dev():
211 _set_bearer_user_vars_local(token, allowed_client_ids, scopes)
212 else:
213 _set_bearer_user_vars(allowed_client_ids, scopes)
214
215
216 def _get_token(request):
217 """Get the auth token for this request.
218
219 Auth token may be specified in either the Authorization header or
220 as a query param (either access_token or bearer_token). We'll check in
221 this order:
222 1. Authorization header.
223 2. bearer_token query param.
224 3. access_token query param.
225
226 Args:
227 request: The current request, or None.
228
229 Returns:
230 The token in the request or None.
231 """
232 # Check if the token is in the Authorization header.
233 auth_header = os.environ.get('HTTP_AUTHORIZATION')
234 if auth_header:
235 allowed_auth_schemes = ('OAuth', 'Bearer')
236 for auth_scheme in allowed_auth_schemes:
237 if auth_header.startswith(auth_scheme):
238 return auth_header[len(auth_scheme) + 1:]
239 # If an auth header was specified, even if it's an invalid one, we won't
240 # look for the token anywhere else.
241 return None
242
243 # Check if the token is in the query string.
244 if request:
245 for key in ('bearer_token', 'access_token'):
246 token, _ = request.get_unrecognized_field_info(key)
247 if token:
248 return token
249
250
251 def _get_id_token_user(token, audiences, allowed_client_ids, time_now, cache):
252 """Get a User for the given id token, if the token is valid.
253
254 Args:
255 token: The id_token to check.
256 audiences: List of audiences that are acceptable.
257 allowed_client_ids: List of client IDs that are acceptable.
258 time_now: The current time as a long (eg. long(time.time())).
259 cache: Cache to use (eg. the memcache module).
260
261 Returns:
262 A User if the token is valid, None otherwise.
263 """
264 # Verify that the token is valid before we try to extract anything from it.
265 # This verifies the signature and some of the basic info in the token.
266 try:
267 parsed_token = _verify_signed_jwt_with_certs(token, time_now, cache)
268 except Exception, e: # pylint: disable=broad-except
269 logging.debug('id_token verification failed: %s', e)
270 return None
271
272 if _verify_parsed_token(parsed_token, audiences, allowed_client_ids):
273 email = parsed_token['email']
274 # The token might have an id, but it's a Gaia ID that's been
275 # obfuscated with the Focus key, rather than the AppEngine (igoogle)
276 # key. If the developer ever put this email into the user DB
277 # and retrieved the ID from that, it'd be different from the ID we'd
278 # return here, so it's safer to not return the ID.
279 # Instead, we'll only return the email.
280 return users.User(email)
281
282
283 # pylint: disable=unused-argument
284 def _set_oauth_user_vars(token_info, audiences, allowed_client_ids, scopes,
285 local_dev):
286 logging.warning('_set_oauth_user_vars is deprecated and will be removed '
287 'soon.')
288 return _set_bearer_user_vars(allowed_client_ids, scopes)
289 # pylint: enable=unused-argument
290
291
292 def _set_bearer_user_vars(allowed_client_ids, scopes):
293 """Validate the oauth bearer token and set endpoints auth user variables.
294
295 If the bearer token is valid, this sets ENDPOINTS_USE_OAUTH_SCOPE. This
296 provides enough information that our endpoints.get_current_user() function
297 can get the user.
298
299 Args:
300 allowed_client_ids: List of client IDs that are acceptable.
301 scopes: List of acceptable scopes.
302 """
303 for scope in scopes:
304 try:
305 client_id = oauth.get_client_id(scope)
306 except oauth.Error:
307 # This scope failed. Try the next.
308 continue
309
310 # The client ID must be in allowed_client_ids. If allowed_client_ids is
311 # empty, don't allow any client ID. If allowed_client_ids is set to
312 # SKIP_CLIENT_ID_CHECK, all client IDs will be allowed.
313 if (list(allowed_client_ids) != SKIP_CLIENT_ID_CHECK and
314 client_id not in allowed_client_ids):
315 logging.warning('Client ID is not allowed: %s', client_id)
316 return
317
318 os.environ[_ENV_USE_OAUTH_SCOPE] = scope
319 logging.debug('Returning user from matched oauth_user.')
320 return
321
322 logging.debug('Oauth framework user didn\'t match oauth token user.')
323 return None
324
325
326 def _set_bearer_user_vars_local(token, allowed_client_ids, scopes):
327 """Validate the oauth bearer token on the dev server.
328
329 Since the functions in the oauth module return only example results in local
330 development, this hits the tokeninfo endpoint and attempts to validate the
331 token. If it's valid, we'll set _ENV_AUTH_EMAIL and _ENV_AUTH_DOMAIN so we
332 can get the user from the token.
333
334 Args:
335 token: String with the oauth token to validate.
336 allowed_client_ids: List of client IDs that are acceptable.
337 scopes: List of acceptable scopes.
338 """
339 # Get token info from the tokeninfo endpoint.
340 result = urlfetch.fetch(
341 '%s?%s' % (_TOKENINFO_URL, urllib.urlencode({'access_token': token})))
342 if result.status_code != 200:
343 try:
344 error_description = json.loads(result.content)['error_description']
345 except (ValueError, KeyError):
346 error_description = ''
347 logging.error('Token info endpoint returned status %s: %s',
348 result.status_code, error_description)
349 return
350 token_info = json.loads(result.content)
351
352 # Validate email.
353 if 'email' not in token_info:
354 logging.warning('Oauth token doesn\'t include an email address.')
355 return
356 if not token_info.get('verified_email'):
357 logging.warning('Oauth token email isn\'t verified.')
358 return
359
360 # Validate client ID.
361 client_id = token_info.get('issued_to')
362 if (list(allowed_client_ids) != SKIP_CLIENT_ID_CHECK and
363 client_id not in allowed_client_ids):
364 logging.warning('Client ID is not allowed: %s', client_id)
365 return
366
367 # Verify at least one of the scopes matches.
368 token_scopes = token_info.get('scope', '').split(' ')
369 if not any(scope in scopes for scope in token_scopes):
370 logging.warning('Oauth token scopes don\'t match any acceptable scopes.')
371 return
372
373 os.environ[_ENV_AUTH_EMAIL] = token_info['email']
374 os.environ[_ENV_AUTH_DOMAIN] = ''
375 logging.debug('Local dev returning user from token.')
376 return
377
378
379 def _is_local_dev():
380 return os.environ.get('SERVER_SOFTWARE', '').startswith('Development')
381
382
383 def _verify_parsed_token(parsed_token, audiences, allowed_client_ids):
384 """Verify a parsed user ID token.
385
386 Args:
387 parsed_token: The parsed token information.
388 audiences: The allowed audiences.
389 allowed_client_ids: The allowed client IDs.
390
391 Returns:
392 True if the token is verified, False otherwise.
393 """
394 # Verify the issuer.
395 if parsed_token.get('iss') not in _ISSUERS:
396 logging.warning('Issuer was not valid: %s', parsed_token.get('iss'))
397 return False
398
399 # Check audiences.
400 aud = parsed_token.get('aud')
401 if not aud:
402 logging.warning('No aud field in token')
403 return False
404 # Special handling if aud == cid. This occurs with iOS and browsers.
405 # As long as audience == client_id and cid is allowed, we need to accept
406 # the audience for compatibility.
407 cid = parsed_token.get('azp')
408 if aud != cid and aud not in audiences:
409 logging.warning('Audience not allowed: %s', aud)
410 return False
411
412 # Check allowed client IDs.
413 if list(allowed_client_ids) == SKIP_CLIENT_ID_CHECK:
414 logging.warning('Client ID check can\'t be skipped for ID tokens. '
415 'Id_token cannot be verified.')
416 return False
417 elif not cid or cid not in allowed_client_ids:
418 logging.warning('Client ID is not allowed: %s', cid)
419 return False
420
421 if 'email' not in parsed_token:
422 return False
423
424 return True
425
426
427 def _urlsafe_b64decode(b64string):
428 # Guard against unicode strings, which base64 can't handle.
429 b64string = b64string.encode('ascii')
430 padded = b64string + '=' * ((4 - len(b64string)) % 4)
431 return base64.urlsafe_b64decode(padded)
432
433
434 def _get_cert_expiration_time(headers):
435 """Get the expiration time for a cert, given the response headers.
436
437 Get expiration time from the headers in the result. If we can't get
438 a time from the headers, this returns 0, indicating that the cert
439 shouldn't be cached.
440
441 Args:
442 headers: A dict containing the response headers from the request to get
443 certs.
444
445 Returns:
446 An integer with the number of seconds the cert should be cached. This
447 value is guaranteed to be >= 0.
448 """
449 # Check the max age of the cert.
450 cache_control = headers.get('Cache-Control', '')
451 # http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 indicates only
452 # a comma-separated header is valid, so it should be fine to split this on
453 # commas.
454 for entry in cache_control.split(','):
455 match = _MAX_AGE_REGEX.match(entry)
456 if match:
457 cache_time_seconds = int(match.group(1))
458 break
459 else:
460 return 0
461
462 # Subtract the cert's age.
463 age = headers.get('Age')
464 if age is not None:
465 try:
466 age = int(age)
467 except ValueError:
468 age = 0
469 cache_time_seconds -= age
470
471 return max(0, cache_time_seconds)
472
473
474 def _get_cached_certs(cert_uri, cache):
475 """Get certs from cache if present; otherwise, gets from URI and caches them.
476
477 Args:
478 cert_uri: URI from which to retrieve certs if cache is stale or empty.
479 cache: Cache of pre-fetched certs.
480
481 Returns:
482 The retrieved certs.
483 """
484 certs = cache.get(cert_uri, namespace=_CERT_NAMESPACE)
485 if certs is None:
486 logging.debug('Cert cache miss')
487 try:
488 result = urlfetch.fetch(cert_uri)
489 except AssertionError:
490 # This happens in unit tests. Act as if we couldn't get any certs.
491 return None
492
493 if result.status_code == 200:
494 certs = json.loads(result.content)
495 expiration_time_seconds = _get_cert_expiration_time(result.headers)
496 if expiration_time_seconds:
497 cache.set(cert_uri, certs, time=expiration_time_seconds,
498 namespace=_CERT_NAMESPACE)
499 else:
500 logging.error(
501 'Certs not available, HTTP request returned %d', result.status_code)
502
503 return certs
504
505
506 def _b64_to_long(b):
507 b = b.encode('ascii')
508 b += '=' * ((4 - len(b)) % 4)
509 b = base64.b64decode(b)
510 return long(b.encode('hex'), 16)
511
512
513 def _verify_signed_jwt_with_certs(
514 jwt, time_now, cache,
515 cert_uri=_DEFAULT_CERT_URI):
516 """Verify a JWT against public certs.
517
518 See http://self-issued.info/docs/draft-jones-json-web-token.html.
519
520 The PyCrypto library included with Google App Engine is severely limited and
521 so you have to use it very carefully to verify JWT signatures. The first
522 issue is that the library can't read X.509 files, so we make a call to a
523 special URI that has the public cert in modulus/exponent form in JSON.
524
525 The second issue is that the RSA.verify method doesn't work, at least for
526 how the JWT tokens are signed, so we have to manually verify the signature
527 of the JWT, which means hashing the signed part of the JWT and comparing
528 that to the signature that's been encrypted with the public key.
529
530 Args:
531 jwt: string, A JWT.
532 time_now: The current time, as a long (eg. long(time.time())).
533 cache: Cache to use (eg. the memcache module).
534 cert_uri: string, URI to get cert modulus and exponent in JSON format.
535
536 Returns:
537 dict, The deserialized JSON payload in the JWT.
538
539 Raises:
540 _AppIdentityError: if any checks are failed.
541 """
542
543 segments = jwt.split('.')
544
545 if len(segments) != 3:
546 # Note that anywhere we print the jwt or its json body, we need to use
547 # %r instead of %s, so that non-printable characters are escaped safely.
548 raise _AppIdentityError('Token is not an id_token (Wrong number of '
549 'segments)')
550 signed = '%s.%s' % (segments[0], segments[1])
551
552 signature = _urlsafe_b64decode(segments[2])
553
554 # pycrypto only deals in integers, so we have to convert the string of bytes
555 # into a long.
556 lsignature = long(signature.encode('hex'), 16)
557
558 # Verify expected header.
559 header_body = _urlsafe_b64decode(segments[0])
560 try:
561 header = json.loads(header_body)
562 except:
563 raise _AppIdentityError("Can't parse header")
564 if header.get('alg') != 'RS256':
565 raise _AppIdentityError('Unexpected encryption algorithm: %r' %
566 header.get('alg'))
567
568 # Parse token.
569 json_body = _urlsafe_b64decode(segments[1])
570 try:
571 parsed = json.loads(json_body)
572 except:
573 raise _AppIdentityError("Can't parse token body")
574
575 certs = _get_cached_certs(cert_uri, cache)
576 if certs is None:
577 raise _AppIdentityError(
578 'Unable to retrieve certs needed to verify the signed JWT')
579
580 # Verify that we were able to load the Crypto libraries, before we try
581 # to use them.
582 if not _CRYPTO_LOADED:
583 raise _AppIdentityError('Unable to load pycrypto library. Can\'t verify '
584 'id_token signature. See http://www.pycrypto.org '
585 'for more information on pycrypto.')
586
587 # SHA256 hash of the already 'signed' segment from the JWT. Since a SHA256
588 # hash, will always have length 64.
589 local_hash = SHA256.new(signed).hexdigest()
590
591 # Check signature.
592 verified = False
593 for keyvalue in certs['keyvalues']:
594 try:
595 modulus = _b64_to_long(keyvalue['modulus'])
596 exponent = _b64_to_long(keyvalue['exponent'])
597 key = RSA.construct((modulus, exponent))
598
599 # Encrypt, and convert to a hex string.
600 hexsig = '%064x' % key.encrypt(lsignature, '')[0]
601 # Make sure we have only last 64 base64 chars
602 hexsig = hexsig[-64:]
603
604 # Check the signature on 'signed' by encrypting 'signature' with the
605 # public key and confirming the result matches the SHA256 hash of
606 # 'signed'.
607 verified = (hexsig == local_hash)
608 if verified:
609 break
610 except Exception, e: # pylint: disable=broad-except
611 # Log the exception for debugging purpose.
612 logging.debug(
613 'Signature verification error: %s; continuing with the next cert.', e)
614 continue
615 if not verified:
616 raise _AppIdentityError('Invalid token signature')
617
618 # Check creation timestamp.
619 iat = parsed.get('iat')
620 if iat is None:
621 raise _AppIdentityError('No iat field in token')
622 earliest = iat - _CLOCK_SKEW_SECS
623
624 # Check expiration timestamp.
625 exp = parsed.get('exp')
626 if exp is None:
627 raise _AppIdentityError('No exp field in token')
628 if exp >= time_now + _MAX_TOKEN_LIFETIME_SECS:
629 raise _AppIdentityError('exp field too far in future')
630 latest = exp + _CLOCK_SKEW_SECS
631
632 if time_now < earliest:
633 raise _AppIdentityError('Token used too early, %d < %d' %
634 (time_now, earliest))
635 if time_now > latest:
636 raise _AppIdentityError('Token used too late, %d > %d' %
637 (time_now, latest))
638
639 return parsed
OLDNEW
« no previous file with comments | « third_party/google-endpoints/endpoints/test/users_id_token_test.py ('k') | third_party/google-endpoints/endpoints/util.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698