Ticket #138: 0009-Add-a-possibility-to-password-protect-the-forms-on-t.patch

File 0009-Add-a-possibility-to-password-protect-the-forms-on-t.patch, 19.0 KB (added by tsuna, 4 years ago)

[PATCH v4 09/12] Add a possibility to password-protect the forms on the WebStatus?.

  • NEWS

    From 9b26de39356f7d7f771d1bdbbfd017e517849b60 Mon Sep 17 00:00:00 2001
    From: Benoit Sigoure <tsuna@lrde.epita.fr>
    Date: Tue, 13 Nov 2007 18:55:52 +0100
    Subject: [PATCH v4 09/12] Add a possibility to password-protect the forms on the WebStatus.
    
    	It is now possible to instantiate the WebStatus with a list of
    	login/password.  Only users with a valid login/password can
    	force/stop builds.
    	* NEWS: Mention the new feature.
    	* buildbot/status/web/authentication.py: New file.
    	(IAuth): New interface.
    	(AuthBase): New base class.
    	(BasicAuth): Implement IAuth.
    	* buildbot/status/web/base.py (make_name_login_password_form): New
    	helper to factor some common code.
    	(make_stop_form, make_force_build_form): Use it.  Accept a 2nd
    	mandatory boolean argument.
    	(HtmlResource.isUsingLoginPassword, HtmlResource.authUser): New
    	dispatch methods.
    	* buildbot/status/web/baseweb.py (OneLinePerBuild.body),
    	(OneBoxPerBuilder.body): Adjust.
    	(WebStatus.__init__): Accept a new `auth' keyword argument.
    	(WebStatus.isUsingLoginPassword, WebStatus.authUser): New.
    	* buildbot/status/web/build.py (StatusResourceBuild.body): Adjust.
    	(StatusResourceBuild.stop): Check the credentials of the user, if
    	needed.
    	* buildbot/status/web/builder.py (StatusResourceBuilder.body),
    	(StatusResourceBuilder.force): Likewise.
    	* docs/buildbot.texinfo (WebStatus Configuration Parameters):
    	Document the new `userpass' argument of WebStatus.
    
    Signed-off-by: Benoit Sigoure <tsuna@lrde.epita.fr>
    ---
     NEWS                                  |    6 +++
     buildbot/status/web/authentication.py |   47 +++++++++++++++++++++
     buildbot/status/web/base.py           |   74 +++++++++++++++++++++++----------
     buildbot/status/web/baseweb.py        |   57 ++++++++++++++++++++++---
     buildbot/status/web/build.py          |    7 +++-
     buildbot/status/web/builder.py        |   20 +++++++--
     docs/buildbot.texinfo                 |   16 ++++++-
     7 files changed, 190 insertions(+), 37 deletions(-)
     create mode 100644 buildbot/status/web/authentication.py
    
    diff --git a/NEWS b/NEWS
    index 26a01eb..7fdcd11 100644
    a b These are now reserved for internal buildbot purposes, such as the magic 
    1010"_all" pseudo-builder that the web pages use to allow force-build buttons 
    1111that start builds on all Builders at once. 
    1212 
     13*** Password protected WebStatus 
     14The WebStatus constructor can take an instance of IAuth (from 
     15status.web.authentication).  The class BasicAuth accepts a `userpass' keyword 
     16argument in pretty much the same way as Try_Userpass does. 
     17Only users with a valid login/password can then force/stop builds from the 
     18WebStatus. 
    1319 
    1420* Release 0.7.6 (30 Sep 2007) 
    1521 
  • new file uildbot/status/web/authentication.py

    diff --git a/buildbot/status/web/authentication.py b/buildbot/status/web/authentication.py
    new file mode 100644
    index 0000000..c8c6875
    - +  
     1 
     2from zope.interface import Interface, implements 
     3 
     4class IAuth(Interface): 
     5    """Represent an authentication method.""" 
     6 
     7    def authenticate(self, login, password): 
     8        """Check whether C{login} / C{password} are valid""" 
     9 
     10    def errmsg(self): 
     11        """Get the last error message (reason why authentication 
     12           failed).""" 
     13 
     14class AuthBase: 
     15    err = "" 
     16 
     17    def errmsg(self): 
     18        return self.err 
     19 
     20class BasicAuth(AuthBase): 
     21    implements(IAuth) 
     22    """Implement a basic authentication mechanism against of list of 
     23       user/password.""" 
     24 
     25    userpass = [] 
     26    """List of couples (user, password)""" 
     27 
     28    def __init__(self, userpass): 
     29        """C{userpass} is a list of (user, password).""" 
     30        for user_pass_pair in userpass: 
     31            assert isinstance(user_pass_pair, tuple) 
     32            login, password = user_pass_pair 
     33            assert isinstance(login, str) 
     34            assert isinstance(password, str) 
     35        self.userpass = userpass 
     36 
     37    def authenticate(self, login, password): 
     38        """Check that C{login}/C{password} is a valid user/password pair.""" 
     39        if not self.userpass: 
     40            self.err = "Invalid value for self.userpass" 
     41            return False 
     42        for l, p in self.userpass: 
     43            if login == l and password == p: 
     44                self.err = "" 
     45                return True 
     46        self.err = "Invalid login or password" 
     47        return False 
  • buildbot/status/web/base.py

    diff --git a/buildbot/status/web/base.py b/buildbot/status/web/base.py
    index b5e8065..7b6e5e8 100644
    a b def make_row(label, field): 
    5353    label = html.escape(label) 
    5454    return ROW_TEMPLATE % {"label": label, "field": field} 
    5555 
    56 def make_stop_form(stopURL, on_all=False): 
     56def make_name_login_password_form(useLoginPassword): 
     57    """helper function that produces either one row of a form with a `name' 
     58       text input (if C{useLoginPassword} is C{False}) or two rows with a 
     59       `login' / `password' text input.""" 
     60 
     61    if useLoginPassword: 
     62        user_label = "Your login:" 
     63    else: 
     64        user_label = "Your name:" 
     65    data = make_row(user_label, 
     66                    '<input type="text" name="username" />') 
     67    if useLoginPassword: 
     68        data += make_row("Your password:", 
     69                         '<input type="password" name="passwd" />') 
     70    return data 
     71 
     72def make_stop_form(stopURL, useLoginPassword, on_all=False): 
     73    """Create a form whose submit button sends the request to C{stopURL}.  If 
     74    C{useLoginPassword} is true, this form will have a password field.""" 
     75 
    5776    if on_all: 
    58         data = """<form action="%s" class='command stopbuild'> 
    59           <p>To stop all builds, fill out the following fields and 
    60           push the 'Stop' button</p>\n""" % stopURL 
     77        what = "all builds" 
    6178    else: 
    62         data = """<form action="%s" class='command stopbuild'> 
    63           <p>To stop this build, fill out the following fields and 
    64           push the 'Stop' button</p>\n""" % stopURL 
    65     data += make_row("Your name:", 
    66                      "<input type='text' name='username' />") 
     79        what = "this build" 
     80    data = """<form action="%s" class="command stopbuild"> 
     81      <p>To stop %s, fill out the following fields and 
     82      click the `Stop' button</p>\n""" % (stopURL, what) 
     83    data += make_name_login_password_form(useLoginPassword) 
     84 
    6785    data += make_row("Reason for stopping build:", 
    68                      "<input type='text' name='comments' />") 
     86                     '<input type="text" name="comments" />') 
    6987    data += '<input type="submit" value="Stop Builder" /></form>\n' 
    7088    return data 
    7189 
    72 def make_force_build_form(forceURL, on_all=False): 
     90def make_force_build_form(forceURL, useLoginPassword, on_all=False): 
     91    """Create a form whose submit button sends the request to C{forceURL}.  If 
     92    C{useLoginPassword} is true, this form will have a password field.""" 
     93 
    7394    if on_all: 
    74         data = """<form action="%s" class="command forcebuild"> 
    75           <p>To force a build on all Builders, fill out the following fields 
    76           and push the 'Force Build' button</p>""" % forceURL 
     95        where = " on all builders" 
    7796    else: 
    78         data = """<form action="%s" class="command forcebuild"> 
    79           <p>To force a build, fill out the following fields and 
    80           push the 'Force Build' button</p>""" % forceURL 
     97        where = "" 
     98    data = """<form action="%s" class="command forcebuild"> 
     99      <p>To force a build%s, fill out the following fields and 
     100      click the `Force Build' button</p>""" % (forceURL, where) 
    81101    return (data 
    82       + make_row("Your name:", 
    83                  "<input type='text' name='username' />") 
     102      + make_name_login_password_form(useLoginPassword) 
    84103      + make_row("Reason for build:", 
    85                  "<input type='text' name='comments' />") 
     104                 '<input type="text" name="comments" />') 
    86105      + make_row("Branch to build:", 
    87                  "<input type='text' name='branch' />") 
     106                 '<input type="text" name="branch" />') 
    88107      + make_row("Revision to build:", 
    89                  "<input type='text' name='revision' />") 
     108                 '<input type="text" name="revision" />') 
    90109      + '<input type="submit" value="Force Build" /></form>\n') 
    91110 
    92111colormap = { 
    class HtmlResource(resource.Resource): 
    254273 
    255274    def getStatus(self, request): 
    256275        return request.site.buildbot_service.getStatus() 
     276 
    257277    def getControl(self, request): 
    258278        return request.site.buildbot_service.getControl() 
    259279 
     280    def isUsingLoginPassword(self, request): 
     281        return request.site.buildbot_service.isUsingLoginPassword() 
     282 
     283    def authUser(self, request): 
     284        login = request.args.get("username", ["<unknown>"])[0] 
     285        password = request.args.get("passwd", ["<no-password>"])[0] 
     286        if login == "<unknown>" or password == "<no-password>": 
     287            return False 
     288        return request.site.buildbot_service.authUser(login, password) 
     289 
    260290    def getChangemaster(self, request): 
    261291        return request.site.buildbot_service.parent.change_svc 
    262292 
  • buildbot/status/web/baseweb.py

    diff --git a/buildbot/status/web/baseweb.py b/buildbot/status/web/baseweb.py
    index 8682735..66b1022 100644
    a b from buildbot.status.web.builder import BuildersResource 
    1919from buildbot.status.web.slaves import BuildSlavesResource 
    2020from buildbot.status.web.xmlrpc import XMLRPCServer 
    2121from buildbot.status.web.about import AboutBuildbot 
     22from buildbot.status.web.authentication import IAuth 
    2223 
    2324# this class contains the status services (WebStatus and the older Waterfall) 
    2425# which can be put in c['status']. It also contains some of the resources 
    class OneLinePerBuild(HtmlResource, OneLineMixin): 
    123124 
    124125        if building: 
    125126            stopURL = "builders/_all/stop" 
    126             data += make_stop_form(stopURL, True) 
     127            data += make_stop_form(stopURL, self.isUsingLoginPassword(req), 
     128                                   True) 
    127129        if online: 
    128130            forceURL = "builders/_all/force" 
    129             data += make_force_build_form(forceURL, True) 
     131            data += make_force_build_form(forceURL, 
     132                                          self.isUsingLoginPassword(req), 
     133                                          True) 
    130134 
    131135        return data 
    132136 
    class OneBoxPerBuilder(HtmlResource): 
    232236 
    233237        if building: 
    234238            stopURL = "builders/_all/stop" 
    235             data += make_stop_form(stopURL, True) 
     239            data += make_stop_form(stopURL, self.isUsingLoginPassword(req), 
     240                                   True) 
    236241        if online: 
    237242            forceURL = "builders/_all/force" 
    238             data += make_force_build_form(forceURL, True) 
     243            data += make_force_build_form(forceURL, 
     244                                          self.isUsingLoginPassword(req), 
     245                                          True) 
    239246 
    240247        return data 
    241248 
    class WebStatus(service.MultiService): 
    350357    # not (we'd have to do a recursive traversal of all children to discover 
    351358    # all the changes). 
    352359 
    353     def __init__(self, http_port=None, distrib_port=None, allowForce=False): 
     360    def __init__(self, http_port=None, distrib_port=None, allowForce=False, 
     361                 auth=None): 
    354362        """Run a web server that provides Buildbot status. 
    355363 
    356364        @type  http_port: int or L{twisted.application.strports} string 
    class WebStatus(service.MultiService): 
    385393                             the strports parser. 
    386394        @param allowForce: boolean, if True then the webserver will allow 
    387395                           visitors to trigger and cancel builds 
     396        @type  auth: a L{status.web.authentication.IAuth} or C{None} 
     397        @param auth: an object that performs authentication to restrain 
     398                     access to the C{allowForce} features.  Ignored if 
     399                     C{allowForce} is not True.  If C{auth} is C{None}, the 
     400                     legacy behavior is used: people can force/stop builds 
     401                     without auth. 
    388402        """ 
    389403 
    390404        service.MultiService.__init__(self) 
    class WebStatus(service.MultiService): 
    398412                distrib_port = "unix:%s" % distrib_port 
    399413        self.distrib_port = distrib_port 
    400414        self.allowForce = allowForce 
     415        if allowForce and auth: 
     416            assert IAuth.providedBy(auth) 
     417            self.auth = auth 
     418        else: 
     419            if auth: 
     420                log.msg("warning: discarding your authentication method: you" 
     421                        "must also set allowForce to True to use one.") 
     422            self.auth = None 
    401423 
    402424        # this will be replaced once we've been attached to a parent (and 
    403425        # thus have a basedir and can reference BASEDIR/public_html/) 
    class WebStatus(service.MultiService): 
    475497        self.site.resource = root 
    476498 
    477499    def putChild(self, name, child_resource): 
    478         """This behaves a lot like root.putChild() . """ 
     500        """This behaves a lot like root.putChild() .""" 
    479501        self.childrenToBeAdded[name] = child_resource 
    480502 
    481503    def registerChannel(self, channel): 
    class WebStatus(service.MultiService): 
    493515 
    494516    def getStatus(self): 
    495517        return self.parent.getStatus() 
     518 
    496519    def getControl(self): 
    497520        if self.allowForce: 
    498521            return IControl(self.parent) 
    499522        return None 
    500523 
     524    def isUsingLoginPassword(self): 
     525        """Return a boolean to indicate whether or not this WebStatus uses a 
     526           list of login/passwords for privileged actions.""" 
     527        if self.auth: 
     528            return True 
     529        return False 
     530 
     531    def authUser(self, login, password): 
     532        """Check that login/password is a valid user/password pair and can be 
     533           allowed to perform a privileged action.  If this WebStatus is not 
     534           password protected, this function returns False (conservative 
     535           approach).""" 
     536        # Do not mess up this function, it's critical for the security of the 
     537        # WebStatus 
     538        if not self.isUsingLoginPassword(): 
     539            return False 
     540        if self.auth.authenticate(login, password): 
     541            return True 
     542        log.msg("Authentication failed for `%s': %s" % (login, 
     543                                                        self.auth.errmsg())) 
     544        return False 
     545 
    501546    def getPortnum(self): 
    502547        # this is for the benefit of unit tests 
    503548        s = list(self)[0] 
  • buildbot/status/web/build.py

    diff --git a/buildbot/status/web/build.py b/buildbot/status/web/build.py
    index 2522b92..432b282 100644
    a b class StatusResourceBuild(HtmlResource): 
    4848 
    4949            if self.build_control is not None: 
    5050                stopURL = urllib.quote(req.childLink("stop")) 
    51                 data += make_stop_form(stopURL) 
     51                data += make_stop_form(stopURL, self.isUsingLoginPassword(req)) 
    5252 
    5353        if b.isFinished(): 
    5454            results = b.getResults() 
    class StatusResourceBuild(HtmlResource): 
    166166        return data 
    167167 
    168168    def stop(self, req): 
     169        if self.isUsingLoginPassword(req): 
     170            if not self.authUser(req): 
     171                # TODO: tell the web user that their request was denied 
     172                return Redirect("..") 
     173 
    169174        b = self.build_status 
    170175        c = self.build_control 
    171176        log.msg("web stopBuild of build %s:%s" % \ 
  • buildbot/status/web/builder.py

    diff --git a/buildbot/status/web/builder.py b/buildbot/status/web/builder.py
    index 1851dd1..fbc8fb5 100644
    a b class StatusResourceBuilder(HtmlResource, OneLineMixin): 
    107107 
    108108        if control is not None and connected_slaves: 
    109109            forceURL = urllib.quote(req.childLink("force")) 
    110             data += make_force_build_form(forceURL) 
     110            data += make_force_build_form(forceURL, 
     111                                          self.isUsingLoginPassword(req)) 
    111112        elif control is not None: 
    112113            data += """ 
    113114            <p>All buildslaves appear to be offline, so it's not possible 
    class StatusResourceBuilder(HtmlResource, OneLineMixin): 
    135136        r = "The web-page 'force build' button was pressed by '%s': %s\n" \ 
    136137            % (name, reason) 
    137138        log.msg("web forcebuild of builder '%s', branch='%s', revision='%s'" 
    138                 % (self.builder_status.getName(), branch, revision)) 
     139                "by user '%s'" % (self.builder_status.getName(), branch, 
     140                                  revision, name)) 
    139141 
    140142        if not self.builder_control: 
    141143            # TODO: tell the web user that their request was denied 
    142144            log.msg("but builder control is disabled") 
    143145            return Redirect("..") 
    144146 
     147        if self.isUsingLoginPassword(req): 
     148            if not self.authUser(req): 
     149                # TODO: tell the web user that their request was denied 
     150                return Redirect("..") 
     151 
    145152        # keep weird stuff out of the branch and revision strings. TODO: 
    146153        # centralize this somewhere. 
    147154        if not re.match(r'^[\w\.\-\/]*$', branch): 
    class StatusResourceBuilder(HtmlResource, OneLineMixin): 
    155162        if not revision: 
    156163            revision = None 
    157164 
    158         # TODO: if we can authenticate that a particular User pushed the 
    159         # button, use their name instead of None, so they'll be informed of 
    160         # the results. 
     165        # TODO: we can authenticate that a particular User clicked the 
     166        # button, so we could use their name instead of None, so they'll be 
     167        # informed of the results.  The problem is that we must create a 
     168        # buildbot.changes.changes.Change instance which doesn't really fit 
     169        # this use case (it requires a list of changed files which is tedious 
     170        # to compute at this stage) 
    161171        s = SourceStamp(branch=branch, revision=revision) 
    162172        req = BuildRequest(r, s, self.builder_status.getName()) 
    163173        try: 
  • docs/buildbot.texinfo

    diff --git a/docs/buildbot.texinfo b/docs/buildbot.texinfo
    index 5c6ca17..278cf4a 100644
    a b True, then the web page will provide a ``Force Build'' button that 
    61206120allows visitors to manually trigger builds. This is useful for 
    61216121developers to re-run builds that have failed because of intermittent 
    61226122problems in the test suite, or because of libraries that were not 
    6123 installed at the time of the previous build. You may not wish to allow 
    6124 strangers to cause a build to run: in that case, set this to False to 
    6125 remove these buttons. The default value is False. 
     6123installed at the time of the previous build.  The default value is False. 
    61266124 
     6125You may not wish to allow strangers to cause a build to run or to stop 
     6126current builds, in that case you can pass an instance of 
     6127@code{status.web.authentication.IAuth} as a @code{auth} keyword argument. 
     6128The class @code{BasicAuth} implements a basic authentication mechanism 
     6129using a list of login/password pairs provided from the configuration file. 
     6130 
     6131@example 
     6132from buildbot.status.html import WebStatus 
     6133from buildbot.status.web.authentication import BasicAuth 
     6134users = [('login', 'password'), ('bob', 'secret-pass')] 
     6135c['status'].append(WebStatus(http_port=8080, auth=BasicAuth(users))) 
     6136@end example 
    61276137 
    61286138 
    61296139@node Buildbot Web Resources, XMLRPC server, WebStatus Configuration Parameters, WebStatus