auth.py 5.1 KB

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