From 62847153d851725a5a8d5c05fa6286a968d3b981 Mon Sep 17 00:00:00 2001 From: Jamie Lennox Date: Fri, 9 Dec 2016 14:04:42 +1100 Subject: [PATCH] Use app_id with github The basics of authenticating to github as an app when posting comments and cloning. This is still a WIP. Change-Id: I11fab75d635a8bcea7210945df4071bf51d7d3f2 --- requirements.txt | 2 + zuul/driver/github/githubconnection.py | 143 ++++++++++++++++++++----- 2 files changed, 121 insertions(+), 24 deletions(-) diff --git a/requirements.txt b/requirements.txt index 3bb7f91788..48ac38e29d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,3 +24,5 @@ sqlalchemy alembic cryptography>=1.6 cachecontrol +pyjwt +iso8601 diff --git a/zuul/driver/github/githubconnection.py b/zuul/driver/github/githubconnection.py index a17ec40762..a09e46fb7d 100644 --- a/zuul/driver/github/githubconnection.py +++ b/zuul/driver/github/githubconnection.py @@ -13,6 +13,7 @@ # under the License. import collections +import datetime import logging import hmac import hashlib @@ -20,6 +21,9 @@ import time import cachecontrol from cachecontrol.cache import DictCache +import iso8601 +import jwt +import requests import webob import webob.dec import voluptuous as v @@ -31,6 +35,25 @@ from zuul.model import Ref from zuul.exceptions import MergeFailure from zuul.driver.github.githubmodel import PullRequest, GithubTriggerEvent +ACCESS_TOKEN_URL = 'https://api.github.com/installations/%s/access_tokens' +PREVIEW_JSON_ACCEPT = 'application/vnd.github.machine-man-preview+json' + + +class UTC(datetime.tzinfo): + """UTC""" + + def utcoffset(self, dt): + return datetime.timedelta(0) + + def tzname(self, dt): + return "UTC" + + def dst(self, dt): + return datetime.timedelta(0) + + +utc = UTC() + class GithubWebhookListener(): @@ -279,20 +302,25 @@ class GithubConnection(BaseConnection): driver_name = 'github' log = logging.getLogger("connection.github") payload_path = 'payload' - git_user = 'git' def __init__(self, driver, connection_name, connection_config): super(GithubConnection, self).__init__( driver, connection_name, connection_config) - self.github = None self._change_cache = {} self.projects = {} - self._git_ssh = bool(self.connection_config.get('sshkey', None)) + self.git_ssh_key = self.connection_config.get('sshkey') self.git_host = self.connection_config.get('git_host', 'github.com') self.canonical_hostname = self.connection_config.get( 'canonical_hostname', self.git_host) self.source = driver.getSource(self) + self._github = None + self.app_id = None + self.app_key = None + self.installation_id = None + self.installation_token = None + self.installation_expiry = None + # NOTE(jamielennox): Better here would be to cache to memcache or file # or something external - but zuul already sucks at restarting so in # memory probably doesn't make this much worse. @@ -310,23 +338,86 @@ class GithubConnection(BaseConnection): self.unregisterHttpHandler(self.payload_path) def _authenticateGithubAPI(self): - token = self.connection_config.get('api_token', None) - if token is not None: - if self.git_host != 'github.com': - url = 'https://%s/' % self.git_host - self.github = github3.enterprise_login(token=token, url=url) - else: - self.github = github3.login(token=token) - self.log.info("Github API Authentication successful.") + config = self.connection_config - # anything going through requests to http/s goes through cache - self.github.session.mount('http://', self.cache_adapter) - self.github.session.mount('https://', self.cache_adapter) + if self.git_host != 'github.com': + url = 'https://%s/' % self.git_host + github = github3.GitHubEnterprise(url) else: - self.github = None - self.log.info( - "No Github credentials found in zuul configuration, cannot " - "authenticate.") + github = github3.GitHub() + + # anything going through requests to http/s goes through cache + github.session.mount('http://', self.cache_adapter) + github.session.mount('https://', self.cache_adapter) + + api_token = config.get('api_token') + + if api_token: + github.login(token=api_token) + else: + app_id = config.get('app_id') + installation_id = config.get('installation_id') + app_key_file = config.get('app_key') + + if app_key_file: + with open(app_key_file, 'r') as f: + app_key = f.read() + + if not (app_id and app_key and installation_id): + self.log.warning("You must provide an app_id, " + "app_key and installation_id to use " + "installation based authentication") + + return + + self.app_id = int(app_id) + self.installation_id = int(installation_id) + self.app_key = app_key + + self._github = github + + def _get_installation_key(self, user_id=None): + if not (self.installation_id and self.app_id): + return None + + now = datetime.datetime.now(utc) + + if ((not self.installation_expiry) or + (not self.installation_token) or + (now < self.installation_expiry)): + expiry = now + datetime.timedelta(minutes=5) + + data = {'iat': now, 'exp': expiry, 'iss': self.app_id} + app_token = jwt.encode(data, + self.app_key, + algorithm='RS256') + + url = ACCESS_TOKEN_URL % self.installation_id + headers = {'Accept': PREVIEW_JSON_ACCEPT, + 'Authorization': 'Bearer %s' % app_token} + json_data = {'user_id': user_id} if user_id else None + + response = requests.post(url, headers=headers, json=json_data) + response.raise_for_status() + + data = response.json() + + self.installation_expiry = iso8601.parse_date(data['expires_at']) + self.installation_expiry -= datetime.timedelta(minutes=5) + self.installation_token = data['token'] + + return self.installation_token + + @property + def github(self): + # if we're using api_key authentication then we don't need to fetch + # new installation tokens so return the existing one. + installation_key = self._get_installation_key() + + if installation_key: + self._github.login(token=installation_key) + + return self._github def maintainCache(self, relevant): for key, change in self._change_cache.items(): @@ -363,12 +454,16 @@ class GithubConnection(BaseConnection): return change def getGitUrl(self, project): - if self._git_ssh: - url = 'ssh://%s@%s/%s.git' % \ - (self.git_user, self.git_host, project) - else: - url = 'https://%s/%s' % (self.git_host, project) - return url + if self.git_ssh_key: + return 'ssh://git@%s/%s.git' % (self.git_host, project) + + installation_key = self._get_installation_key() + if installation_key: + return 'https://x-access-token:%s@%s/%s' % (installation_key, + self.git_host, + project) + + return 'https://%s/%s' % (self.git_host, project) def getGitwebUrl(self, project, sha=None): url = 'https://%s/%s' % (self.git_host, project)