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