11"""
22Flask extension for rapid GitHub app development
33"""
4- import os .path
54import hmac
5+ import time
66import logging
7- import distutils
7+ import requests
8+ import jwt
9+
10+ from flask import abort , current_app , jsonify , make_response , request , _app_ctx_stack
11+ from distutils .util import strtobool
12+ from os import environ
13+ from ghapi .all import GhApi
814
9- from flask import abort , current_app , jsonify , request , _app_ctx_stack
10- from github3 import GitHub , GitHubEnterprise
1115
1216LOG = logging .getLogger (__name__ )
1317
1418STATUS_FUNC_CALLED = "HIT"
1519STATUS_NO_FUNC_CALLED = "MISS"
1620
1721
18- class GitHubApp (object ):
22+ class GitHubAppError (Exception ):
23+ pass
24+
25+
26+ class GitHubAppValidationError (Exception ):
27+ pass
28+
29+
30+ class GitHubAppBadCredentials (Exception ):
31+ pass
32+
33+
34+ class GithubUnauthorized (Exception ):
35+ pass
36+
37+
38+ class GithubAppUnkownObject (Exception ):
39+ pass
40+
41+
42+ class InstallationAuthorization :
1943 """
20- The GitHubApp object provides the central interface for interacting GitHub hooks
44+ This class represents InstallationAuthorizations
45+ """
46+
47+ def __init__ (self , token , expires_at ):
48+ self .token = token
49+ self .expires_at = expires_at
50+
51+ def token (self ):
52+ return self ._token
53+
54+ def expires_at (self ):
55+ return self ._expires_at
56+
57+ def expired (self ):
58+ if self .expires_at :
59+ return time .time () > self .expires_at
60+ return False
61+
62+
63+ class GitHubApp (object ):
64+ """The GitHubApp object provides the central interface for interacting GitHub hooks
2165 and creating GitHub app clients.
2266
2367 GitHubApp object allows using the "on" decorator to make GitHub hooks to functions
@@ -29,24 +73,24 @@ class GitHubApp(object):
2973
3074 def __init__ (self , app = None ):
3175 self ._hook_mappings = {}
76+ self ._access_token = None
3277 if app is not None :
3378 self .init_app (app )
3479
3580 @staticmethod
3681 def load_env (app ):
37- app .config ["GITHUBAPP_ID" ] = int (os . environ ["APP_ID" ])
38- app .config ["GITHUBAPP_SECRET" ] = os . environ ["WEBHOOK_SECRET" ]
39- if "GHE_HOST" in os . environ :
40- app .config ["GITHUBAPP_URL" ] = "https://{}" .format (os . environ ["GHE_HOST" ])
82+ app .config ["GITHUBAPP_ID" ] = int (environ ["APP_ID" ])
83+ app .config ["GITHUBAPP_SECRET" ] = environ ["WEBHOOK_SECRET" ]
84+ if "GHE_HOST" in environ :
85+ app .config ["GITHUBAPP_URL" ] = "https://{}" .format (environ ["GHE_HOST" ])
4186 app .config ["VERIFY_SSL" ] = bool (
42- distutils . util . strtobool (os . environ .get ("VERIFY_SSL" , "false" ))
87+ strtobool (environ .get ("VERIFY_SSL" , "false" ))
4388 )
44- with open (os . environ ["PRIVATE_KEY_PATH" ], "rb" ) as key_file :
89+ with open (environ ["PRIVATE_KEY_PATH" ], "rb" ) as key_file :
4590 app .config ["GITHUBAPP_KEY" ] = key_file .read ()
4691
4792 def init_app (self , app ):
48- """
49- Initializes GitHubApp app by setting configuration variables.
93+ """Initializes GitHubApp app by setting configuration variables.
5094
5195 The GitHubApp instance is given the following configuration variables by calling on Flask's configuration:
5296
@@ -62,7 +106,8 @@ def init_app(self, app):
62106
63107 `GITHUBAPP_SECRET`:
64108
65- Secret used to secure webhooks as bytes or utf-8 encoded string (required).
109+ Secret used to secure webhooks as bytes or utf-8 encoded string (required). set to `False` to disable
110+ verification (not recommended for production).
66111 Default: None
67112
68113 `GITHUBAPP_URL`:
@@ -75,14 +120,19 @@ def init_app(self, app):
75120 Path used for GitHub hook requests as a string.
76121 Default: '/'
77122 """
78- self .load_env (app )
79123 required_settings = ["GITHUBAPP_ID" , "GITHUBAPP_KEY" , "GITHUBAPP_SECRET" ]
80124 for setting in required_settings :
81- if not app .config . get ( setting ) :
125+ if not setting in app .config :
82126 raise RuntimeError (
83- "Flask-GitHubApp requires the '%s' config var to be set" % setting
127+ "Flask-GitHubApplication requires the '%s' config var to be set"
128+ % setting
84129 )
85130
131+ if app .config .get ("GITHUBAPP_URL" ):
132+ self .base_url = app .config .get ("GITHUBAPP_URL" )
133+ else :
134+ self .base_url = "https://api.github.com"
135+
86136 app .add_url_rule (
87137 app .config .get ("GITHUBAPP_ROUTE" , "/" ),
88138 view_func = self ._flask_view_func ,
@@ -111,16 +161,6 @@ def secret(self):
111161 def _api_url (self ):
112162 return current_app .config ["GITHUBAPP_URL" ]
113163
114- @property
115- def client (self ):
116- """Unauthenticated GitHub client"""
117- if current_app .config .get ("GITHUBAPP_URL" ):
118- return GitHubEnterprise (
119- current_app .config ["GITHUBAPP_URL" ],
120- verify = current_app .config ["VERIFY_SSL" ],
121- )
122- return GitHub ()
123-
124164 @property
125165 def payload (self ):
126166 """GitHub hook payload"""
@@ -132,58 +172,97 @@ def payload(self):
132172 )
133173
134174 @property
135- def installation_client (self ):
175+ def installation_token (self ):
176+ return self ._access_token
177+
178+ def client (self , installation_id = None ):
136179 """GitHub client authenticated as GitHub app installation"""
137180 ctx = _app_ctx_stack .top
138181 if ctx is not None :
139182 if not hasattr (ctx , "githubapp_installation" ):
140- client = self .client
141- client .login_as_app_installation (
142- self .key , self .id , self .payload ["installation" ]["id" ]
143- )
144- ctx .githubapp_installation = client
183+ if installation_id is None :
184+ installation_id = self .payload ["installation" ]["id" ]
185+ self ._access_token = self .get_access_token (installation_id ).token
186+ ctx .githubapp_installation = GhApi (token = self ._access_token )
145187 return ctx .githubapp_installation
146188
147- @property
148- def app_client (self ):
149- """GitHub client authenticated as GitHub app"""
150- ctx = _app_ctx_stack .top
151- if ctx is not None :
152- if not hasattr (ctx , "githubapp_app" ):
153- client = self .client
154- client .login_as_app (self .key , self .id )
155- ctx .githubapp_app = client
156- return ctx .githubapp_app
157-
158- @property
159- def installation_token (self ):
189+ def _create_jwt (self , expiration = 60 ):
160190 """
191+ Creates a signed JWT, valid for 60 seconds by default.
192+ The expiration can be extended beyond this, to a maximum of 600 seconds.
193+ :param expiration: int
194+ :return string:
195+ """
196+ now = int (time .time ())
197+ payload = {"iat" : now , "exp" : now + expiration , "iss" : self .id }
198+ encrypted = jwt .encode (payload , key = self .key , algorithm = "RS256" )
199+
200+ if isinstance (encrypted , bytes ):
201+ encrypted = encrypted .decode ("utf-8" )
202+ return encrypted
161203
162- :return :
204+ def get_access_token ( self , installation_id , user_id = None ) :
163205 """
164- return self .installation_client .session .auth .token
206+ Get an access token for the given installation id.
207+ POSTs https://api.github.com/app/installations/<installation_id>/access_tokens
208+ :param user_id: int
209+ :param installation_id: int
210+ :return: :class:`github.InstallationAuthorization.InstallationAuthorization`
211+ """
212+ body = {}
213+ if user_id :
214+ body = {"user_id" : user_id }
215+ response = requests .post (
216+ f"{ self .base_url } /app/installations/{ installation_id } /access_tokens" ,
217+ headers = {
218+ "Authorization" : f"Bearer { self ._create_jwt ()} " ,
219+ "Accept" : "application/vnd.github.v3+json" ,
220+ "User-Agent" : "Flask-GithubApplication/Python" ,
221+ },
222+ json = body ,
223+ )
224+ if response .status_code == 201 :
225+ return InstallationAuthorization (
226+ token = response .json ()["token" ], expires_at = response .json ()["expires_at" ]
227+ )
228+ elif response .status_code == 403 :
229+ raise GitHubAppBadCredentials (
230+ status = response .status_code , data = response .text
231+ )
232+ elif response .status_code == 404 :
233+ raise GithubAppUnkownObject (status = response .status_code , data = response .text )
234+ raise Exception (status = response .status_code , data = response .text )
165235
166- def app_installation (self , installation_id = None ):
236+ def list_installations (self , per_page = 30 , page = 1 ):
167237 """
168- Login as installation when triggered on a non-webhook event.
169- This is necessary for scheduling tasks
170- :param installation_id:
171- :return:
238+ GETs https://api.github.com/app/installations
239+ :return: :obj: `list` of installations
172240 """
173- """GitHub client authenticated as GitHub app installation"""
174- ctx = _app_ctx_stack .top
175- if installation_id is None :
176- raise RuntimeError ("Installation ID is not specified." )
177- if ctx is not None :
178- if not hasattr (ctx , "githubapp_installation" ):
179- client = self .client
180- client .login_as_app_installation (self .key , self .id , installation_id )
181- ctx .githubapp_installation = client
182- return ctx .githubapp_installation
241+ params = {"page" : page , "per_page" : per_page }
242+
243+ response = requests .get (
244+ f"{ self .base_url } /app/installations" ,
245+ headers = {
246+ "Authorization" : f"Bearer { self ._create_jwt ()} " ,
247+ "Accept" : "application/vnd.github.v3+json" ,
248+ "User-Agent" : "Flask-GithubApplication/python" ,
249+ },
250+ params = params ,
251+ )
252+ if response .status_code == 200 :
253+ return response .json ()
254+ elif response .status_code == 401 :
255+ raise GithubUnauthorized (status = response .status_code , data = response .text )
256+ elif response .status_code == 403 :
257+ raise GitHubAppBadCredentials (
258+ status = response .status_code , data = response .text
259+ )
260+ elif response .status_code == 404 :
261+ raise GithubAppUnkownObject (status = response .status_code , data = response .text )
262+ raise Exception (status = response .status_code , data = response .text )
183263
184264 def on (self , event_action ):
185- """
186- Decorator routes a GitHub hook to the wrapped function.
265+ """Decorator routes a GitHub hook to the wrapped function.
187266
188267 Functions decorated as a hook recipient are registered as the function for the given GitHub event.
189268
@@ -192,7 +271,7 @@ def cruel_closer():
192271 owner = github_app.payload['repository']['owner']['login']
193272 repo = github_app.payload['repository']['name']
194273 num = github_app.payload['issue']['id']
195- issue = github_app.installation_client .issue(owner, repo, num)
274+ issue = github_app.client .issue(owner, repo, num)
196275 issue.create_comment('Could not replicate.')
197276 issue.close()
198277
@@ -213,14 +292,41 @@ def decorator(f):
213292
214293 return decorator
215294
295+ def _validate_request (self ):
296+ if not request .is_json :
297+ raise GitHubAppValidationError (
298+ "Invalid HTTP Content-Type header for JSON body "
299+ "(must be application/json or application/*+json)."
300+ )
301+ try :
302+ request .json
303+ except BadRequest :
304+ raise GitHubAppValidationError ("Invalid HTTP body (must be JSON)." )
305+
306+ event = request .headers .get ("X-GitHub-Event" )
307+
308+ if event is None :
309+ raise GitHubAppValidationError ("Missing X-GitHub-Event HTTP header." )
310+
311+ action = request .json .get ("action" )
312+
313+ return event , action
314+
216315 def _flask_view_func (self ):
217316 functions_to_call = []
218317 calls = {}
219318
220- event = request .headers ["X-GitHub-Event" ]
221- action = request .json .get ("action" )
319+ try :
320+ event , action = self ._validate_request ()
321+ except GitHubAppValidationError as e :
322+ LOG .error (e )
323+ error_response = make_response (
324+ jsonify (status = "ERROR" , description = str (e )), 400
325+ )
326+ return abort (error_response )
222327
223- self ._verify_webhook ()
328+ if current_app .config ["GITHUBAPP_SECRET" ] is not False :
329+ self ._verify_webhook ()
224330
225331 if event in self ._hook_mappings :
226332 functions_to_call += self ._hook_mappings [event ]
@@ -239,15 +345,24 @@ def _flask_view_func(self):
239345 return jsonify ({"status" : status , "calls" : calls })
240346
241347 def _verify_webhook (self ):
242- hub_signature = "X-HUB-SIGNATURE"
243- if hub_signature not in request .headers :
244- LOG .warning ("Github Hook Signature not found." )
245- abort (400 )
246-
247- signature = request .headers [hub_signature ].split ("=" )[1 ]
348+ signature_header = "X-Hub-Signature-256"
349+ signature_header_legacy = "X-Hub-Signature"
350+
351+ if request .headers .get (signature_header ):
352+ signature = request .headers [signature_header ].split ("=" )[1 ]
353+ digestmod = "sha256"
354+ elif request .headers .get (signature_header_legacy ):
355+ signature = request .headers [signature_header_legacy ].split ("=" )[1 ]
356+ digestmod = "sha1"
357+ else :
358+ LOG .warning (
359+ "Signature header missing. Configure your GitHub App with a secret or set GITHUBAPP_SECRET"
360+ "to False to disable verification."
361+ )
362+ return abort (400 )
248363
249- mac = hmac .new (self .secret , msg = request .data , digestmod = "sha1" )
364+ mac = hmac .new (self .secret , msg = request .data , digestmod = digestmod )
250365
251366 if not hmac .compare_digest (mac .hexdigest (), signature ):
252367 LOG .warning ("GitHub hook signature verification failed." )
253- abort (400 )
368+ return abort (400 )
0 commit comments