| # Copyright 2015 The Chromium Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Google OAuth2 related functions.""" |
| |
| from __future__ import print_function |
| |
| import collections |
| import datetime |
| import functools |
| import httplib2 |
| import json |
| import logging |
| import os |
| |
| import subprocess2 |
| |
| |
| # This is what most GAE apps require for authentication. |
| OAUTH_SCOPE_EMAIL = 'https://www.googleapis.com/auth/userinfo.email' |
| # Gerrit and Git on *.googlesource.com require this scope. |
| OAUTH_SCOPE_GERRIT = 'https://www.googleapis.com/auth/gerritcodereview' |
| # Deprecated. Use OAUTH_SCOPE_EMAIL instead. |
| OAUTH_SCOPES = OAUTH_SCOPE_EMAIL |
| |
| |
| # Mockable datetime.datetime.utcnow for testing. |
| def datetime_now(): |
| return datetime.datetime.utcnow() |
| |
| |
| # OAuth access token with its expiration time (UTC datetime or None if unknown). |
| class AccessToken(collections.namedtuple('AccessToken', [ |
| 'token', |
| 'expires_at', |
| ])): |
| |
| def needs_refresh(self): |
| """True if this AccessToken should be refreshed.""" |
| if self.expires_at is not None: |
| # Allow 30s of clock skew between client and backend. |
| return datetime_now() + datetime.timedelta(seconds=30) >= self.expires_at |
| # Token without expiration time never expires. |
| return False |
| |
| |
| class LoginRequiredError(Exception): |
| """Interaction with the user is required to authenticate.""" |
| |
| def __init__(self, scopes=OAUTH_SCOPE_EMAIL): |
| msg = ( |
| 'You are not logged in. Please login first by running:\n' |
| ' luci-auth login -scopes %s' % scopes) |
| super(LoginRequiredError, self).__init__(msg) |
| |
| |
| def has_luci_context_local_auth(): |
| """Returns whether LUCI_CONTEXT should be used for ambient authentication.""" |
| ctx_path = os.environ.get('LUCI_CONTEXT') |
| if not ctx_path: |
| return False |
| try: |
| with open(ctx_path) as f: |
| loaded = json.load(f) |
| except (OSError, IOError, ValueError): |
| return False |
| return loaded.get('local_auth', {}).get('default_account_id') is not None |
| |
| |
| class Authenticator(object): |
| """Object that knows how to refresh access tokens when needed. |
| |
| Args: |
| scopes: space separated oauth scopes. Defaults to OAUTH_SCOPE_EMAIL. |
| """ |
| |
| def __init__(self, scopes=OAUTH_SCOPE_EMAIL): |
| self._access_token = None |
| self._scopes = scopes |
| |
| def has_cached_credentials(self): |
| """Returns True if credentials can be obtained. |
| |
| If returns False, get_access_token() later will probably ask for interactive |
| login by raising LoginRequiredError. |
| |
| If returns True, get_access_token() won't ask for interactive login. |
| """ |
| return bool(self._get_luci_auth_token()) |
| |
| def get_access_token(self): |
| """Returns AccessToken, refreshing it if necessary. |
| |
| Raises: |
| LoginRequiredError if user interaction is required. |
| """ |
| if self._access_token and not self._access_token.needs_refresh(): |
| return self._access_token |
| |
| # Token expired or missing. Maybe some other process already updated it, |
| # reload from the cache. |
| self._access_token = self._get_luci_auth_token() |
| if self._access_token and not self._access_token.needs_refresh(): |
| return self._access_token |
| |
| # Nope, still expired. Needs user interaction. |
| logging.error('Failed to create access token') |
| raise LoginRequiredError(self._scopes) |
| |
| def authorize(self, http): |
| """Monkey patches authentication logic of httplib2.Http instance. |
| |
| The modified http.request method will add authentication headers to each |
| request. |
| |
| Args: |
| http: An instance of httplib2.Http. |
| |
| Returns: |
| A modified instance of http that was passed in. |
| """ |
| # Adapted from oauth2client.OAuth2Credentials.authorize. |
| request_orig = http.request |
| |
| @functools.wraps(request_orig) |
| def new_request( |
| uri, method='GET', body=None, headers=None, |
| redirections=httplib2.DEFAULT_MAX_REDIRECTS, |
| connection_type=None): |
| headers = (headers or {}).copy() |
| headers['Authorization'] = 'Bearer %s' % self.get_access_token().token |
| return request_orig( |
| uri, method, body, headers, redirections, connection_type) |
| |
| http.request = new_request |
| return http |
| |
| ## Private methods. |
| |
| def _run_luci_auth_login(self): |
| """Run luci-auth login. |
| |
| Returns: |
| AccessToken with credentials. |
| """ |
| logging.debug('Running luci-auth login') |
| subprocess2.check_call(['luci-auth', 'login', '-scopes', self._scopes]) |
| return self._get_luci_auth_token() |
| |
| def _get_luci_auth_token(self): |
| logging.debug('Running luci-auth token') |
| try: |
| out, err = subprocess2.check_call_out( |
| ['luci-auth', 'token', '-scopes', self._scopes, '-json-output', '-'], |
| stdout=subprocess2.PIPE, stderr=subprocess2.PIPE) |
| logging.debug('luci-auth token stderr:\n%s', err) |
| token_info = json.loads(out) |
| return AccessToken( |
| token_info['token'], |
| datetime.datetime.utcfromtimestamp(token_info['expiry'])) |
| except subprocess2.CalledProcessError: |
| return None |