11"""
22Flask extension for rapid GitHub app development
33"""
4- import os .path
54import hmac
5+ import time
66import logging
7- import distutils
87
9- from flask import abort , current_app , jsonify , request , _app_ctx_stack
10- from github3 import GitHub , GitHubEnterprise
8+ from flask import abort , current_app , jsonify , make_response , request , _app_ctx_stack
9+ from ghapi .all import GhApi
10+ import requests
11+ import jwt
1112
1213LOG = logging .getLogger (__name__ )
1314
1415STATUS_FUNC_CALLED = "HIT"
1516STATUS_NO_FUNC_CALLED = "MISS"
1617
1718
18- class GitHubApp (object ):
19+ class GitHubAppError (Exception ):
20+ pass
21+
22+
23+ class GitHubAppValidationError (Exception ):
24+ pass
25+
26+
27+ class GitHubAppBadCredentials (Exception ):
28+ pass
29+
30+
31+ class GithubUnauthorized (Exception ):
32+ pass
33+
34+
35+ class GithubAppUnkownObject (Exception ):
36+ pass
37+
38+
39+ class InstallationAuthorization :
40+ """
41+ This class represents InstallationAuthorizations
1942 """
20- The GitHubApp object provides the central interface for interacting GitHub hooks
43+
44+ def __init__ (self , token , expires_at ):
45+ self .token = token
46+ self .expires_at = expires_at
47+
48+ def token (self ):
49+ return self ._token
50+
51+ def expires_at (self ):
52+ return self ._expires_at
53+
54+ def expired (self ):
55+ if self .expires_at :
56+ return time .time () > self .expires_at
57+ return False
58+
59+
60+ class GitHubApp (object ):
61+ """The GitHubApp object provides the central interface for interacting GitHub hooks
2162 and creating GitHub app clients.
2263
2364 GitHubApp object allows using the "on" decorator to make GitHub hooks to functions
@@ -29,24 +70,12 @@ class GitHubApp(object):
2970
3071 def __init__ (self , app = None ):
3172 self ._hook_mappings = {}
73+ self ._access_token = None
3274 if app is not None :
3375 self .init_app (app )
3476
35- @staticmethod
36- 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" ])
41- app .config ["VERIFY_SSL" ] = bool (
42- distutils .util .strtobool (os .environ .get ("VERIFY_SSL" , "false" ))
43- )
44- with open (os .environ ["PRIVATE_KEY_PATH" ], "rb" ) as key_file :
45- app .config ["GITHUBAPP_KEY" ] = key_file .read ()
46-
4777 def init_app (self , app ):
48- """
49- Initializes GitHubApp app by setting configuration variables.
78+ """Initializes GitHubApp app by setting configuration variables.
5079
5180 The GitHubApp instance is given the following configuration variables by calling on Flask's configuration:
5281
@@ -62,7 +91,8 @@ def init_app(self, app):
6291
6392 `GITHUBAPP_SECRET`:
6493
65- Secret used to secure webhooks as bytes or utf-8 encoded string (required).
94+ Secret used to secure webhooks as bytes or utf-8 encoded string (required). set to `False` to disable
95+ verification (not recommended for production).
6696 Default: None
6797
6898 `GITHUBAPP_URL`:
@@ -75,14 +105,19 @@ def init_app(self, app):
75105 Path used for GitHub hook requests as a string.
76106 Default: '/'
77107 """
78- self .load_env (app )
79108 required_settings = ["GITHUBAPP_ID" , "GITHUBAPP_KEY" , "GITHUBAPP_SECRET" ]
80109 for setting in required_settings :
81- if not app .config . get ( setting ) :
110+ if not setting in app .config :
82111 raise RuntimeError (
83- "Flask-GitHubApp requires the '%s' config var to be set" % setting
112+ "Flask-GitHubApplication requires the '%s' config var to be set"
113+ % setting
84114 )
85115
116+ if app .config .get ("GITHUBAPP_URL" ):
117+ self .base_url = app .config .get ("GITHUBAPP_URL" )
118+ else :
119+ self .base_url = "https://api.github.com"
120+
86121 app .add_url_rule (
87122 app .config .get ("GITHUBAPP_ROUTE" , "/" ),
88123 view_func = self ._flask_view_func ,
@@ -111,16 +146,6 @@ def secret(self):
111146 def _api_url (self ):
112147 return current_app .config ["GITHUBAPP_URL" ]
113148
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-
124149 @property
125150 def payload (self ):
126151 """GitHub hook payload"""
@@ -132,58 +157,97 @@ def payload(self):
132157 )
133158
134159 @property
135- def installation_client (self ):
160+ def installation_token (self ):
161+ return self ._access_token
162+
163+ def client (self , installation_id = None ):
136164 """GitHub client authenticated as GitHub app installation"""
137165 ctx = _app_ctx_stack .top
138166 if ctx is not None :
139167 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
168+ if installation_id is None :
169+ installation_id = self .payload ["installation" ]["id" ]
170+ self ._access_token = self .get_access_token (installation_id ).token
171+ ctx .githubapp_installation = GhApi (token = self ._access_token )
145172 return ctx .githubapp_installation
146173
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 ):
174+ def _create_jwt (self , expiration = 60 ):
175+ """
176+ Creates a signed JWT, valid for 60 seconds by default.
177+ The expiration can be extended beyond this, to a maximum of 600 seconds.
178+ :param expiration: int
179+ :return string:
160180 """
181+ now = int (time .time ())
182+ payload = {"iat" : now , "exp" : now + expiration , "iss" : self .id }
183+ encrypted = jwt .encode (payload , key = self .key , algorithm = "RS256" )
161184
162- :return:
185+ if isinstance (encrypted , bytes ):
186+ encrypted = encrypted .decode ("utf-8" )
187+ return encrypted
188+
189+ def get_access_token (self , installation_id , user_id = None ):
190+ """
191+ Get an access token for the given installation id.
192+ POSTs https://api.github.com/app/installations/<installation_id>/access_tokens
193+ :param user_id: int
194+ :param installation_id: int
195+ :return: :class:`github.InstallationAuthorization.InstallationAuthorization`
163196 """
164- return self .installation_client .session .auth .token
197+ body = {}
198+ if user_id :
199+ body = {"user_id" : user_id }
200+ response = requests .post (
201+ f"{ self .base_url } /app/installations/{ installation_id } /access_tokens" ,
202+ headers = {
203+ "Authorization" : f"Bearer { self ._create_jwt ()} " ,
204+ "Accept" : "application/vnd.github.v3+json" ,
205+ "User-Agent" : "Flask-GithubApplication/Python" ,
206+ },
207+ json = body ,
208+ )
209+ if response .status_code == 201 :
210+ return InstallationAuthorization (
211+ token = response .json ()["token" ], expires_at = response .json ()["expires_at" ]
212+ )
213+ elif response .status_code == 403 :
214+ raise GitHubAppBadCredentials (
215+ status = response .status_code , data = response .text
216+ )
217+ elif response .status_code == 404 :
218+ raise GithubAppUnkownObject (status = response .status_code , data = response .text )
219+ raise Exception (status = response .status_code , data = response .text )
165220
166- def app_installation (self , installation_id = None ):
221+ def list_installations (self , per_page = 30 , page = 1 ):
167222 """
168- Login as installation when triggered on a non-webhook event.
169- This is necessary for scheduling tasks
170- :param installation_id:
171- :return:
223+ GETs https://api.github.com/app/installations
224+ :return: :obj: `list` of installations
172225 """
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
226+ params = {"page" : page , "per_page" : per_page }
227+
228+ response = requests .get (
229+ f"{ self .base_url } /app/installations" ,
230+ headers = {
231+ "Authorization" : f"Bearer { self ._create_jwt ()} " ,
232+ "Accept" : "application/vnd.github.v3+json" ,
233+ "User-Agent" : "Flask-GithubApplication/python" ,
234+ },
235+ params = params ,
236+ )
237+ if response .status_code == 200 :
238+ return response .json ()
239+ elif response .status_code == 401 :
240+ raise GithubUnauthorized (status = response .status_code , data = response .text )
241+ elif response .status_code == 403 :
242+ raise GitHubAppBadCredentials (
243+ status = response .status_code , data = response .text
244+ )
245+ elif response .status_code == 404 :
246+ raise GithubAppUnkownObject (status = response .status_code , data = response .text )
247+ raise Exception (status = response .status_code , data = response .text )
183248
184249 def on (self , event_action ):
185- """
186- Decorator routes a GitHub hook to the wrapped function.
250+ """Decorator routes a GitHub hook to the wrapped function.
187251
188252 Functions decorated as a hook recipient are registered as the function for the given GitHub event.
189253
@@ -192,7 +256,7 @@ def cruel_closer():
192256 owner = github_app.payload['repository']['owner']['login']
193257 repo = github_app.payload['repository']['name']
194258 num = github_app.payload['issue']['id']
195- issue = github_app.installation_client .issue(owner, repo, num)
259+ issue = github_app.client .issue(owner, repo, num)
196260 issue.create_comment('Could not replicate.')
197261 issue.close()
198262
@@ -213,14 +277,41 @@ def decorator(f):
213277
214278 return decorator
215279
280+ def _validate_request (self ):
281+ if not request .is_json :
282+ raise GitHubAppValidationError (
283+ "Invalid HTTP Content-Type header for JSON body "
284+ "(must be application/json or application/*+json)."
285+ )
286+ try :
287+ request .json
288+ except BadRequest :
289+ raise GitHubAppValidationError ("Invalid HTTP body (must be JSON)." )
290+
291+ event = request .headers .get ("X-GitHub-Event" )
292+
293+ if event is None :
294+ raise GitHubAppValidationError ("Missing X-GitHub-Event HTTP header." )
295+
296+ action = request .json .get ("action" )
297+
298+ return event , action
299+
216300 def _flask_view_func (self ):
217301 functions_to_call = []
218302 calls = {}
219303
220- event = request .headers ["X-GitHub-Event" ]
221- action = request .json .get ("action" )
304+ try :
305+ event , action = self ._validate_request ()
306+ except GitHubAppValidationError as e :
307+ LOG .error (e )
308+ error_response = make_response (
309+ jsonify (status = "ERROR" , description = str (e )), 400
310+ )
311+ return abort (error_response )
222312
223- self ._verify_webhook ()
313+ if current_app .config ["GITHUBAPP_SECRET" ] is not False :
314+ self ._verify_webhook ()
224315
225316 if event in self ._hook_mappings :
226317 functions_to_call += self ._hook_mappings [event ]
@@ -239,15 +330,24 @@ def _flask_view_func(self):
239330 return jsonify ({"status" : status , "calls" : calls })
240331
241332 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 ]
333+ signature_header = "X-Hub-Signature-256"
334+ signature_header_legacy = "X-Hub-Signature"
335+
336+ if request .headers .get (signature_header ):
337+ signature = request .headers [signature_header ].split ("=" )[1 ]
338+ digestmod = "sha256"
339+ elif request .headers .get (signature_header_legacy ):
340+ signature = request .headers [signature_header_legacy ].split ("=" )[1 ]
341+ digestmod = "sha1"
342+ else :
343+ LOG .warning (
344+ "Signature header missing. Configure your GitHub App with a secret or set GITHUBAPP_SECRET"
345+ "to False to disable verification."
346+ )
347+ return abort (400 )
248348
249- mac = hmac .new (self .secret , msg = request .data , digestmod = "sha1" )
349+ mac = hmac .new (self .secret , msg = request .data , digestmod = digestmod )
250350
251351 if not hmac .compare_digest (mac .hexdigest (), signature ):
252352 LOG .warning ("GitHub hook signature verification failed." )
253- abort (400 )
353+ return abort (400 )
0 commit comments