PageRenderTime 42ms CodeModel.GetById 13ms RepoModel.GetById 0ms app.codeStats 1ms

/master/buildbot/www/oauth2.py

https://github.com/djmitche/buildbot
Python | 388 lines | 324 code | 37 blank | 27 comment | 13 complexity | 06cb4ccac9c470b8c272b72bf9770e2a MD5 | raw file
Possible License(s): GPL-2.0
  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. from __future__ import absolute_import
  16. from __future__ import print_function
  17. from future.moves.urllib.parse import parse_qs
  18. from future.moves.urllib.parse import urlencode
  19. from future.utils import iteritems
  20. import json
  21. import re
  22. import textwrap
  23. from posixpath import join
  24. import jinja2
  25. import requests
  26. from twisted.internet import defer
  27. from twisted.internet import threads
  28. from buildbot import config
  29. from buildbot.util import bytes2unicode
  30. from buildbot.util.logger import Logger
  31. from buildbot.www import auth
  32. from buildbot.www import resource
  33. log = Logger()
  34. class OAuth2LoginResource(auth.LoginResource):
  35. # disable reconfigResource calls
  36. needsReconfig = False
  37. def __init__(self, master, _auth):
  38. auth.LoginResource.__init__(self, master)
  39. self.auth = _auth
  40. def render_POST(self, request):
  41. return self.asyncRenderHelper(request, self.renderLogin)
  42. @defer.inlineCallbacks
  43. def renderLogin(self, request):
  44. code = request.args.get(b"code", [b""])[0]
  45. token = request.args.get(b"token", [b""])[0]
  46. if not token and not code:
  47. url = request.args.get(b"redirect", [None])[0]
  48. url = yield self.auth.getLoginURL(url)
  49. raise resource.Redirect(url)
  50. else:
  51. if not token:
  52. details = yield self.auth.verifyCode(code)
  53. else:
  54. details = yield self.auth.acceptToken(token)
  55. if self.auth.userInfoProvider is not None:
  56. infos = yield self.auth.userInfoProvider.getUserInfo(details['username'])
  57. details.update(infos)
  58. session = request.getSession()
  59. session.user_info = details
  60. session.updateSession(request)
  61. state = request.args.get(b"state", [b""])[0]
  62. if state:
  63. for redirect in parse_qs(state).get('redirect', []):
  64. raise resource.Redirect(self.auth.homeUri + "#" + redirect)
  65. raise resource.Redirect(self.auth.homeUri)
  66. class OAuth2Auth(auth.AuthBase):
  67. name = 'oauth2'
  68. getTokenUseAuthHeaders = False
  69. authUri = None
  70. tokenUri = None
  71. grantType = 'authorization_code'
  72. authUriAdditionalParams = {}
  73. tokenUriAdditionalParams = {}
  74. loginUri = None
  75. homeUri = None
  76. sslVerify = None
  77. def __init__(self,
  78. clientId, clientSecret, autologin=False, **kwargs):
  79. auth.AuthBase.__init__(self, **kwargs)
  80. self.clientId = clientId
  81. self.clientSecret = clientSecret
  82. self.autologin = autologin
  83. def reconfigAuth(self, master, new_config):
  84. self.master = master
  85. self.loginUri = join(new_config.buildbotURL, "auth/login")
  86. self.homeUri = new_config.buildbotURL
  87. def getConfigDict(self):
  88. return dict(name=self.name,
  89. oauth2=True,
  90. fa_icon=self.faIcon,
  91. autologin=self.autologin
  92. )
  93. def getLoginResource(self):
  94. return OAuth2LoginResource(self.master, self)
  95. def getLoginURL(self, redirect_url):
  96. """
  97. Returns the url to redirect the user to for user consent
  98. """
  99. oauth_params = {'redirect_uri': self.loginUri,
  100. 'client_id': self.clientId, 'response_type': 'code'}
  101. if redirect_url is not None:
  102. oauth_params['state'] = urlencode(dict(redirect=redirect_url))
  103. oauth_params.update(self.authUriAdditionalParams)
  104. sorted_oauth_params = sorted(oauth_params.items(), key=lambda val: val[0])
  105. return defer.succeed("%s?%s" % (self.authUri, urlencode(sorted_oauth_params)))
  106. def createSessionFromToken(self, token):
  107. s = requests.Session()
  108. s.params = {'access_token': token['access_token']}
  109. s.verify = self.sslVerify
  110. return s
  111. def get(self, session, path):
  112. ret = session.get(self.resourceEndpoint + path)
  113. return ret.json()
  114. # If the user wants to authenticate directly with an access token they
  115. # already have, go ahead and just directly accept an access_token from them.
  116. def acceptToken(self, token):
  117. def thd():
  118. session = self.createSessionFromToken({'access_token': token})
  119. return self.getUserInfoFromOAuthClient(session)
  120. return threads.deferToThread(thd)
  121. # based on https://github.com/maraujop/requests-oauth
  122. # from Miguel Araujo, augmented to support header based clientSecret
  123. # passing
  124. def verifyCode(self, code):
  125. # everything in deferToThread is not counted with trial --coverage :-(
  126. def thd():
  127. url = self.tokenUri
  128. data = {'redirect_uri': self.loginUri, 'code': code,
  129. 'grant_type': self.grantType}
  130. auth = None
  131. if self.getTokenUseAuthHeaders:
  132. auth = (self.clientId, self.clientSecret)
  133. else:
  134. data.update(
  135. {'client_id': self.clientId, 'client_secret': self.clientSecret})
  136. data.update(self.tokenUriAdditionalParams)
  137. response = requests.post(
  138. url, data=data, auth=auth, verify=self.sslVerify)
  139. response.raise_for_status()
  140. responseContent = bytes2unicode(response.content)
  141. try:
  142. content = json.loads(responseContent)
  143. except ValueError:
  144. content = parse_qs(responseContent)
  145. for k, v in iteritems(content):
  146. content[k] = v[0]
  147. except TypeError:
  148. content = responseContent
  149. session = self.createSessionFromToken(content)
  150. return self.getUserInfoFromOAuthClient(session)
  151. return threads.deferToThread(thd)
  152. def getUserInfoFromOAuthClient(self, c):
  153. return {}
  154. class GoogleAuth(OAuth2Auth):
  155. name = "Google"
  156. faIcon = "fa-google-plus"
  157. resourceEndpoint = "https://www.googleapis.com/oauth2/v1"
  158. authUri = 'https://accounts.google.com/o/oauth2/auth'
  159. tokenUri = 'https://accounts.google.com/o/oauth2/token'
  160. authUriAdditionalParams = dict(scope=" ".join([
  161. 'https://www.googleapis.com/auth/userinfo.email',
  162. 'https://www.googleapis.com/auth/userinfo.profile'
  163. ]))
  164. def getUserInfoFromOAuthClient(self, c):
  165. data = self.get(c, '/userinfo')
  166. return dict(full_name=data["name"],
  167. username=data['email'].split("@")[0],
  168. email=data["email"],
  169. avatar_url=data["picture"])
  170. class GitHubAuth(OAuth2Auth):
  171. name = "GitHub"
  172. faIcon = "fa-github"
  173. authUri = 'https://github.com/login/oauth/authorize'
  174. authUriAdditionalParams = {'scope': 'user:email read:org'}
  175. tokenUri = 'https://github.com/login/oauth/access_token'
  176. resourceEndpoint = 'https://api.github.com'
  177. getUserTeamsGraphqlTpl = textwrap.dedent(r'''
  178. {%- if organizations %}
  179. query getOrgTeamMembership {
  180. {%- for org_slug, org_name in organizations.items() %}
  181. {{ org_slug }}: organization(login: "{{ org_name }}") {
  182. teams(first: 100) {
  183. edges {
  184. node {
  185. name,
  186. slug
  187. }
  188. }
  189. }
  190. }
  191. {%- endfor %}
  192. }
  193. {%- endif %}
  194. ''')
  195. def __init__(self,
  196. clientId, clientSecret, serverURL=None, autologin=False,
  197. apiVersion=3, getTeamsMembership=False, debug=False,
  198. **kwargs):
  199. OAuth2Auth.__init__(self, clientId, clientSecret, autologin, **kwargs)
  200. if serverURL is not None:
  201. # setup for enterprise github
  202. if serverURL.endswith("/"):
  203. serverURL = serverURL[:-1]
  204. # v3 is accessible directly at /api/v3 for enterprise, but directly for SaaS..
  205. self.resourceEndpoint = serverURL + '/api/v3'
  206. self.authUri = '{0}/login/oauth/authorize'.format(serverURL)
  207. self.tokenUri = '{0}/login/oauth/access_token'.format(serverURL)
  208. self.serverURL = serverURL or self.resourceEndpoint
  209. if apiVersion not in (3, 4):
  210. config.error(
  211. 'GitHubAuth apiVersion must be 3 or 4 not {}'.format(
  212. apiVersion))
  213. self.apiVersion = apiVersion
  214. if apiVersion == 3:
  215. if getTeamsMembership is True:
  216. config.error(
  217. 'Retrieving team membership information using GitHubAuth is only '
  218. 'possible using GitHub api v4.')
  219. else:
  220. self.apiResourceEndpoint = self.serverURL + '/graphql'
  221. if getTeamsMembership:
  222. # GraphQL name aliases must comply with /^[_a-zA-Z][_a-zA-Z0-9]*$/
  223. self._orgname_slug_sub_re = re.compile(r'[^_a-zA-Z0-9]')
  224. self.getUserTeamsGraphqlTplC = jinja2.Template(
  225. self.getUserTeamsGraphqlTpl.strip())
  226. self.getTeamsMembership = getTeamsMembership
  227. self.debug = debug
  228. def post(self, session, query):
  229. if self.debug:
  230. log.info('{klass} GraphQL POST Request: {endpoint} -> '
  231. 'DATA:\n----\n{data}\n----',
  232. klass=self.__class__.__name__,
  233. endpoint=self.apiResourceEndpoint,
  234. data=query)
  235. ret = session.post(self.apiResourceEndpoint, json={'query': query})
  236. return ret.json()
  237. def getUserInfoFromOAuthClient(self, c):
  238. if self.apiVersion == 3:
  239. return self.getUserInfoFromOAuthClient_v3(c)
  240. return self.getUserInfoFromOAuthClient_v4(c)
  241. def getUserInfoFromOAuthClient_v3(self, c):
  242. user = self.get(c, '/user')
  243. emails = self.get(c, '/user/emails')
  244. for email in emails:
  245. if email.get('primary', False):
  246. user['email'] = email['email']
  247. break
  248. orgs = self.get(c, '/user/orgs')
  249. return dict(full_name=user['name'],
  250. email=user['email'],
  251. username=user['login'],
  252. groups=[org['login'] for org in orgs])
  253. def getUserInfoFromOAuthClient_v4(self, c):
  254. graphql_query = textwrap.dedent('''
  255. query {
  256. viewer {
  257. email
  258. login
  259. name
  260. organizations(first: 100) {
  261. edges {
  262. node {
  263. login
  264. }
  265. }
  266. }
  267. }
  268. }
  269. ''')
  270. data = self.post(c, graphql_query.strip())
  271. data = data['data']
  272. if self.debug:
  273. log.info('{klass} GraphQL Response: {response}',
  274. klass=self.__class__.__name__,
  275. response=data)
  276. user_info = dict(full_name=data['viewer']['name'],
  277. email=data['viewer']['email'],
  278. username=data['viewer']['login'],
  279. groups=[org['node']['login'] for org in
  280. data['viewer']['organizations']['edges']])
  281. if self.getTeamsMembership:
  282. orgs_name_slug_mapping = dict(
  283. [(self._orgname_slug_sub_re.sub('_', n), n)
  284. for n in user_info['groups']])
  285. graphql_query = self.getUserTeamsGraphqlTplC.render(
  286. {'user_info': user_info,
  287. 'organizations': orgs_name_slug_mapping})
  288. if graphql_query:
  289. data = self.post(c, graphql_query)
  290. if self.debug:
  291. log.info('{klass} GraphQL Response: {response}',
  292. klass=self.__class__.__name__,
  293. response=data)
  294. teams = set()
  295. for org, team_data in data['data'].items():
  296. for node in team_data['teams']['edges']:
  297. # On github we can mentions organization teams like
  298. # @org-name/team-name. Let's keep the team formatting
  299. # identical with the inclusion of the organization
  300. # since different organizations might share a common
  301. # team name
  302. teams.add('%s/%s' % (orgs_name_slug_mapping[org], node['node']['name']))
  303. user_info['groups'].extend(sorted(teams))
  304. if self.debug:
  305. log.info('{klass} User Details: {user_info}',
  306. klass=self.__class__.__name__,
  307. user_info=user_info)
  308. return user_info
  309. class GitLabAuth(OAuth2Auth):
  310. name = "GitLab"
  311. faIcon = "fa-git"
  312. def __init__(self, instanceUri, clientId, clientSecret, **kwargs):
  313. uri = instanceUri.rstrip("/")
  314. self.authUri = "%s/oauth/authorize" % uri
  315. self.tokenUri = "%s/oauth/token" % uri
  316. self.resourceEndpoint = "%s/api/v3" % uri
  317. super(GitLabAuth, self).__init__(clientId, clientSecret, **kwargs)
  318. def getUserInfoFromOAuthClient(self, c):
  319. user = self.get(c, "/user")
  320. groups = self.get(c, "/groups")
  321. return dict(full_name=user["name"],
  322. username=user["username"],
  323. email=user["email"],
  324. avatar_url=user["avatar_url"],
  325. groups=[g["path"] for g in groups])
  326. class BitbucketAuth(OAuth2Auth):
  327. name = "Bitbucket"
  328. faIcon = "fa-bitbucket"
  329. authUri = 'https://bitbucket.org/site/oauth2/authorize'
  330. tokenUri = 'https://bitbucket.org/site/oauth2/access_token'
  331. resourceEndpoint = 'https://api.bitbucket.org/2.0'
  332. def getUserInfoFromOAuthClient(self, c):
  333. user = self.get(c, '/user')
  334. emails = self.get(c, '/user/emails')
  335. for email in emails["values"]:
  336. if email.get('is_primary', False):
  337. user['email'] = email['email']
  338. break
  339. orgs = self.get(c, '/teams?role=member')
  340. return dict(full_name=user['display_name'],
  341. email=user['email'],
  342. username=user['username'],
  343. groups=[org['username'] for org in orgs["values"]])