auth.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. # Copyright 2015 The Chromium Authors. All rights reserved.
  2. # Use of this source code is governed by a BSD-style license that can be
  3. # found in the LICENSE file.
  4. """Google OAuth2 related functions."""
  5. from __future__ import print_function
  6. import collections
  7. import datetime
  8. import functools
  9. import httplib2
  10. import json
  11. import logging
  12. import os
  13. import subprocess2
  14. # TODO: Should fix these warnings.
  15. # pylint: disable=line-too-long
  16. # This is what most GAE apps require for authentication.
  17. OAUTH_SCOPE_EMAIL = 'https://www.googleapis.com/auth/userinfo.email'
  18. # Gerrit and Git on *.googlesource.com require this scope.
  19. OAUTH_SCOPE_GERRIT = 'https://www.googleapis.com/auth/gerritcodereview'
  20. # Deprecated. Use OAUTH_SCOPE_EMAIL instead.
  21. OAUTH_SCOPES = OAUTH_SCOPE_EMAIL
  22. # Mockable datetime.datetime.utcnow for testing.
  23. def datetime_now():
  24. return datetime.datetime.utcnow()
  25. # OAuth access token with its expiration time (UTC datetime or None if unknown).
  26. class AccessToken(
  27. collections.namedtuple('AccessToken', [
  28. 'token',
  29. 'expires_at',
  30. ])):
  31. def needs_refresh(self):
  32. """True if this AccessToken should be refreshed."""
  33. if self.expires_at is not None:
  34. # Allow 30s of clock skew between client and backend.
  35. return datetime_now() + datetime.timedelta(
  36. seconds=30) >= self.expires_at
  37. # Token without expiration time never expires.
  38. return False
  39. class LoginRequiredError(Exception):
  40. """Interaction with the user is required to authenticate."""
  41. def __init__(self, scopes=OAUTH_SCOPE_EMAIL):
  42. msg = ('You are not logged in. Please login first by running:\n'
  43. ' luci-auth login -scopes %s' % scopes)
  44. super(LoginRequiredError, self).__init__(msg)
  45. def has_luci_context_local_auth():
  46. """Returns whether LUCI_CONTEXT should be used for ambient authentication."""
  47. ctx_path = os.environ.get('LUCI_CONTEXT')
  48. if not ctx_path:
  49. return False
  50. try:
  51. with open(ctx_path) as f:
  52. loaded = json.load(f)
  53. except (OSError, IOError, ValueError):
  54. return False
  55. return loaded.get('local_auth', {}).get('default_account_id') is not None
  56. class Authenticator(object):
  57. """Object that knows how to refresh access tokens when needed.
  58. Args:
  59. scopes: space separated oauth scopes. Defaults to OAUTH_SCOPE_EMAIL.
  60. """
  61. def __init__(self, scopes=OAUTH_SCOPE_EMAIL):
  62. self._access_token = None
  63. self._scopes = scopes
  64. def has_cached_credentials(self):
  65. """Returns True if credentials can be obtained.
  66. If returns False, get_access_token() later will probably ask for interactive
  67. login by raising LoginRequiredError.
  68. If returns True, get_access_token() won't ask for interactive login.
  69. """
  70. return bool(self._get_luci_auth_token())
  71. def get_access_token(self):
  72. """Returns AccessToken, refreshing it if necessary.
  73. Raises:
  74. LoginRequiredError if user interaction is required.
  75. """
  76. if self._access_token and not self._access_token.needs_refresh():
  77. return self._access_token
  78. # Token expired or missing. Maybe some other process already updated it,
  79. # reload from the cache.
  80. self._access_token = self._get_luci_auth_token()
  81. if self._access_token and not self._access_token.needs_refresh():
  82. return self._access_token
  83. # Nope, still expired. Needs user interaction.
  84. logging.error('Failed to create access token')
  85. raise LoginRequiredError(self._scopes)
  86. def authorize(self, http):
  87. """Monkey patches authentication logic of httplib2.Http instance.
  88. The modified http.request method will add authentication headers to each
  89. request.
  90. Args:
  91. http: An instance of httplib2.Http.
  92. Returns:
  93. A modified instance of http that was passed in.
  94. """
  95. # Adapted from oauth2client.OAuth2Credentials.authorize.
  96. request_orig = http.request
  97. @functools.wraps(request_orig)
  98. def new_request(uri,
  99. method='GET',
  100. body=None,
  101. headers=None,
  102. redirections=httplib2.DEFAULT_MAX_REDIRECTS,
  103. connection_type=None):
  104. headers = (headers or {}).copy()
  105. headers['Authorization'] = 'Bearer %s' % self.get_access_token(
  106. ).token
  107. return request_orig(uri, method, body, headers, redirections,
  108. connection_type)
  109. http.request = new_request
  110. return http
  111. ## Private methods.
  112. def _run_luci_auth_login(self):
  113. """Run luci-auth login.
  114. Returns:
  115. AccessToken with credentials.
  116. """
  117. logging.debug('Running luci-auth login')
  118. subprocess2.check_call(['luci-auth', 'login', '-scopes', self._scopes])
  119. return self._get_luci_auth_token()
  120. def _get_luci_auth_token(self):
  121. logging.debug('Running luci-auth token')
  122. try:
  123. out, err = subprocess2.check_call_out([
  124. 'luci-auth', 'token', '-scopes', self._scopes, '-json-output',
  125. '-'
  126. ],
  127. stdout=subprocess2.PIPE,
  128. stderr=subprocess2.PIPE)
  129. logging.debug('luci-auth token stderr:\n%s', err)
  130. token_info = json.loads(out)
  131. return AccessToken(
  132. token_info['token'],
  133. datetime.datetime.utcfromtimestamp(token_info['expiry']))
  134. except subprocess2.CalledProcessError as e:
  135. # subprocess2.CalledProcessError.__str__ nicely formats
  136. # stdout/stderr.
  137. logging.error('luci-auth token failed: %s', e)
  138. return None