1# This file is part of Buildbot. Buildbot is free software: you can 2# redistribute it and/or modify it under the terms of the GNU General Public 3# License as published by the Free Software Foundation, version 2. 4# 5# This program is distributed in the hope that it will be useful, but WITHOUT 6# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 7# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 8# details. 9# 10# You should have received a copy of the GNU General Public License along with 11# this program; if not, write to the Free Software Foundation, Inc., 51 12# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 13# 14# Copyright Buildbot Team Members 15 16import json 17import re 18import textwrap 19from posixpath import join 20from urllib.parse import parse_qs 21from urllib.parse import urlencode 22 23import jinja2 24import requests 25 26from twisted.internet import defer 27from twisted.internet import threads 28 29import buildbot 30from buildbot import config 31from buildbot.process.properties import Properties 32from buildbot.util import bytes2unicode 33from buildbot.util.logger import Logger 34from buildbot.www import auth 35from buildbot.www import resource 36 37log = Logger() 38 39 40class OAuth2LoginResource(auth.LoginResource): 41 # disable reconfigResource calls 42 needsReconfig = False 43 44 def __init__(self, master, _auth): 45 super().__init__(master) 46 self.auth = _auth 47 48 def render_POST(self, request): 49 return self.asyncRenderHelper(request, self.renderLogin) 50 51 @defer.inlineCallbacks 52 def renderLogin(self, request): 53 code = request.args.get(b"code", [b""])[0] 54 if not code: 55 url = request.args.get(b"redirect", [None])[0] 56 url = yield self.auth.getLoginURL(url) 57 raise resource.Redirect(url) 58 59 details = yield self.auth.verifyCode(code) 60 61 if self.auth.userInfoProvider is not None: 62 infos = yield self.auth.userInfoProvider.getUserInfo(details['username']) 63 details.update(infos) 64 session = request.getSession() 65 session.user_info = details 66 session.updateSession(request) 67 state = request.args.get(b"state", [b""])[0] 68 if state: 69 for redirect in parse_qs(state).get('redirect', []): 70 raise resource.Redirect(self.auth.homeUri + "#" + redirect) 71 raise resource.Redirect(self.auth.homeUri) 72 73 74class OAuth2Auth(auth.AuthBase): 75 name = 'oauth2' 76 getTokenUseAuthHeaders = False 77 authUri = None 78 tokenUri = None 79 grantType = 'authorization_code' 80 authUriAdditionalParams = {} 81 tokenUriAdditionalParams = {} 82 loginUri = None 83 homeUri = None 84 sslVerify = None 85 86 def __init__(self, 87 clientId, clientSecret, autologin=False, **kwargs): 88 super().__init__(**kwargs) 89 self.clientId = clientId 90 self.clientSecret = clientSecret 91 self.autologin = autologin 92 93 def reconfigAuth(self, master, new_config): 94 self.master = master 95 self.loginUri = join(new_config.buildbotURL, "auth/login") 96 self.homeUri = new_config.buildbotURL 97 98 def getConfigDict(self): 99 return dict(name=self.name, 100 oauth2=True, 101 fa_icon=self.faIcon, 102 autologin=self.autologin 103 ) 104 105 def getLoginResource(self): 106 return OAuth2LoginResource(self.master, self) 107 108 @defer.inlineCallbacks 109 def getLoginURL(self, redirect_url): 110 """ 111 Returns the url to redirect the user to for user consent 112 """ 113 p = Properties() 114 p.master = self.master 115 clientId = yield p.render(self.clientId) 116 oauth_params = {'redirect_uri': self.loginUri, 117 'client_id': clientId, 'response_type': 'code'} 118 if redirect_url is not None: 119 oauth_params['state'] = urlencode(dict(redirect=redirect_url)) 120 oauth_params.update(self.authUriAdditionalParams) 121 sorted_oauth_params = sorted(oauth_params.items(), key=lambda val: val[0]) 122 return "{}?{}".format(self.authUri, urlencode(sorted_oauth_params)) 123 124 def createSessionFromToken(self, token): 125 s = requests.Session() 126 s.params = {'access_token': token['access_token']} 127 s.verify = self.sslVerify 128 return s 129 130 def get(self, session, path): 131 ret = session.get(self.resourceEndpoint + path) 132 return ret.json() 133 134 # based on https://github.com/maraujop/requests-oauth 135 # from Miguel Araujo, augmented to support header based clientSecret 136 # passing 137 @defer.inlineCallbacks 138 def verifyCode(self, code): 139 # everything in deferToThread is not counted with trial --coverage :-( 140 def thd(client_id, client_secret): 141 url = self.tokenUri 142 data = {'redirect_uri': self.loginUri, 'code': code, 143 'grant_type': self.grantType} 144 auth = None 145 if self.getTokenUseAuthHeaders: 146 auth = (client_id, client_secret) 147 else: 148 data.update( 149 {'client_id': client_id, 'client_secret': client_secret}) 150 data.update(self.tokenUriAdditionalParams) 151 response = requests.post( 152 url, data=data, auth=auth, verify=self.sslVerify) 153 response.raise_for_status() 154 responseContent = bytes2unicode(response.content) 155 try: 156 content = json.loads(responseContent) 157 except ValueError: 158 content = parse_qs(responseContent) 159 for k, v in content.items(): 160 content[k] = v[0] 161 except TypeError: 162 content = responseContent 163 164 session = self.createSessionFromToken(content) 165 return self.getUserInfoFromOAuthClient(session) 166 p = Properties() 167 p.master = self.master 168 client_id = yield p.render(self.clientId) 169 client_secret = yield p.render(self.clientSecret) 170 result = yield threads.deferToThread(thd, client_id, client_secret) 171 return result 172 173 def getUserInfoFromOAuthClient(self, c): 174 return {} 175 176 177class GoogleAuth(OAuth2Auth): 178 name = "Google" 179 faIcon = "fa-google-plus" 180 resourceEndpoint = "https://www.googleapis.com/oauth2/v1" 181 authUri = 'https://accounts.google.com/o/oauth2/auth' 182 tokenUri = 'https://accounts.google.com/o/oauth2/token' 183 authUriAdditionalParams = dict(scope=" ".join([ 184 'https://www.googleapis.com/auth/userinfo.email', 185 'https://www.googleapis.com/auth/userinfo.profile' 186 ])) 187 188 def getUserInfoFromOAuthClient(self, c): 189 data = self.get(c, '/userinfo') 190 return dict(full_name=data["name"], 191 username=data['email'].split("@")[0], 192 email=data["email"], 193 avatar_url=data["picture"]) 194 195 196class GitHubAuth(OAuth2Auth): 197 name = "GitHub" 198 faIcon = "fa-github" 199 authUri = 'https://github.com/login/oauth/authorize' 200 authUriAdditionalParams = {'scope': 'user:email read:org'} 201 tokenUri = 'https://github.com/login/oauth/access_token' 202 resourceEndpoint = 'https://api.github.com' 203 204 getUserTeamsGraphqlTpl = textwrap.dedent(r''' 205 {%- if organizations %} 206 query getOrgTeamMembership { 207 {%- for org_slug, org_name in organizations.items() %} 208 {{ org_slug }}: organization(login: "{{ org_name }}") { 209 teams(first: 100 userLogins: ["{{ user_info.username }}"]) { 210 edges { 211 node { 212 name, 213 slug 214 } 215 } 216 } 217 } 218 {%- endfor %} 219 } 220 {%- endif %} 221 ''') 222 223 def __init__(self, 224 clientId, clientSecret, serverURL=None, autologin=False, 225 apiVersion=3, getTeamsMembership=False, debug=False, 226 **kwargs): 227 228 super().__init__(clientId, clientSecret, autologin, **kwargs) 229 self.apiResourceEndpoint = None 230 if serverURL is not None: 231 # setup for enterprise github 232 serverURL = serverURL.rstrip("/") 233 # v3 is accessible directly at /api/v3 for enterprise, but directly for SaaS.. 234 self.resourceEndpoint = serverURL + '/api/v3' 235 # v4 is accessible endpoint for enterprise 236 self.apiResourceEndpoint = serverURL + '/api/graphql' 237 238 self.authUri = '{0}/login/oauth/authorize'.format(serverURL) 239 self.tokenUri = '{0}/login/oauth/access_token'.format(serverURL) 240 self.serverURL = serverURL or self.resourceEndpoint 241 242 if apiVersion not in (3, 4): 243 config.error( 244 'GitHubAuth apiVersion must be 3 or 4 not {}'.format( 245 apiVersion)) 246 self.apiVersion = apiVersion 247 if apiVersion == 3: 248 if getTeamsMembership is True: 249 config.error( 250 'Retrieving team membership information using GitHubAuth is only ' 251 'possible using GitHub api v4.') 252 else: 253 defaultGraphqlEndpoint = self.serverURL + '/graphql' 254 self.apiResourceEndpoint = self.apiResourceEndpoint or defaultGraphqlEndpoint 255 if getTeamsMembership: 256 # GraphQL name aliases must comply with /^[_a-zA-Z][_a-zA-Z0-9]*$/ 257 self._orgname_slug_sub_re = re.compile(r'[^_a-zA-Z0-9]') 258 self.getUserTeamsGraphqlTplC = jinja2.Template( 259 self.getUserTeamsGraphqlTpl.strip()) 260 self.getTeamsMembership = getTeamsMembership 261 self.debug = debug 262 263 def post(self, session, query): 264 if self.debug: 265 log.info('{klass} GraphQL POST Request: {endpoint} -> ' 266 'DATA:\n----\n{data}\n----', 267 klass=self.__class__.__name__, 268 endpoint=self.apiResourceEndpoint, 269 data=query) 270 ret = session.post(self.apiResourceEndpoint, json={'query': query}) 271 return ret.json() 272 273 def getUserInfoFromOAuthClient(self, c): 274 if self.apiVersion == 3: 275 return self.getUserInfoFromOAuthClient_v3(c) 276 return self.getUserInfoFromOAuthClient_v4(c) 277 278 def getUserInfoFromOAuthClient_v3(self, c): 279 user = self.get(c, '/user') 280 emails = self.get(c, '/user/emails') 281 for email in emails: 282 if email.get('primary', False): 283 user['email'] = email['email'] 284 break 285 orgs = self.get(c, '/user/orgs') 286 return dict(full_name=user['name'], 287 email=user['email'], 288 username=user['login'], 289 groups=[org['login'] for org in orgs]) 290 291 def createSessionFromToken(self, token): 292 s = requests.Session() 293 s.headers = { 294 'Authorization': 'token ' + token['access_token'], 295 'User-Agent': 'buildbot/{}'.format(buildbot.version), 296 } 297 s.verify = self.sslVerify 298 return s 299 300 def getUserInfoFromOAuthClient_v4(self, c): 301 graphql_query = textwrap.dedent(''' 302 query { 303 viewer { 304 email 305 login 306 name 307 organizations(first: 100) { 308 edges { 309 node { 310 login 311 } 312 } 313 } 314 } 315 } 316 ''') 317 data = self.post(c, graphql_query.strip()) 318 data = data['data'] 319 if self.debug: 320 log.info('{klass} GraphQL Response: {response}', 321 klass=self.__class__.__name__, 322 response=data) 323 user_info = dict(full_name=data['viewer']['name'], 324 email=data['viewer']['email'], 325 username=data['viewer']['login'], 326 groups=[org['node']['login'] for org in 327 data['viewer']['organizations']['edges']]) 328 if self.getTeamsMembership: 329 orgs_name_slug_mapping = { 330 self._orgname_slug_sub_re.sub('_', n): n 331 for n in user_info['groups']} 332 graphql_query = self.getUserTeamsGraphqlTplC.render( 333 {'user_info': user_info, 334 'organizations': orgs_name_slug_mapping}) 335 if graphql_query: 336 data = self.post(c, graphql_query) 337 if self.debug: 338 log.info('{klass} GraphQL Response: {response}', 339 klass=self.__class__.__name__, 340 response=data) 341 teams = set() 342 for org, team_data in data['data'].items(): 343 if team_data is None: 344 # Organizations can have OAuth App access restrictions enabled, 345 # disallowing team data access to third-parties. 346 continue 347 for node in team_data['teams']['edges']: 348 # On github we can mentions organization teams like 349 # @org-name/team-name. Let's keep the team formatting 350 # identical with the inclusion of the organization 351 # since different organizations might share a common 352 # team name 353 teams.add('{}/{}'.format(orgs_name_slug_mapping[org], node['node']['name'])) 354 teams.add('{}/{}'.format(orgs_name_slug_mapping[org], node['node']['slug'])) 355 user_info['groups'].extend(sorted(teams)) 356 if self.debug: 357 log.info('{klass} User Details: {user_info}', 358 klass=self.__class__.__name__, 359 user_info=user_info) 360 return user_info 361 362 363class GitLabAuth(OAuth2Auth): 364 name = "GitLab" 365 faIcon = "fa-git" 366 367 def __init__(self, instanceUri, clientId, clientSecret, **kwargs): 368 uri = instanceUri.rstrip("/") 369 self.authUri = "{}/oauth/authorize".format(uri) 370 self.tokenUri = "{}/oauth/token".format(uri) 371 self.resourceEndpoint = "{}/api/v4".format(uri) 372 super().__init__(clientId, clientSecret, **kwargs) 373 374 def getUserInfoFromOAuthClient(self, c): 375 user = self.get(c, "/user") 376 groups = self.get(c, "/groups") 377 return dict(full_name=user["name"], 378 username=user["username"], 379 email=user["email"], 380 avatar_url=user["avatar_url"], 381 groups=[g["path"] for g in groups]) 382 383 384class BitbucketAuth(OAuth2Auth): 385 name = "Bitbucket" 386 faIcon = "fa-bitbucket" 387 authUri = 'https://bitbucket.org/site/oauth2/authorize' 388 tokenUri = 'https://bitbucket.org/site/oauth2/access_token' 389 resourceEndpoint = 'https://api.bitbucket.org/2.0' 390 391 def getUserInfoFromOAuthClient(self, c): 392 user = self.get(c, '/user') 393 emails = self.get(c, '/user/emails') 394 for email in emails["values"]: 395 if email.get('is_primary', False): 396 user['email'] = email['email'] 397 break 398 orgs = self.get(c, '/teams?role=member') 399 return dict(full_name=user['display_name'], 400 email=user['email'], 401 username=user['username'], 402 groups=[org['username'] for org in orgs["values"]]) 403