Repository: buildbot/buildbot

Issue:
UserPasswordAuth: broken on py3?
reported by @aliceinwire on IRC

if auth is configured with:
```
c['www']['auth'] = util.UserPasswordAuth(usernames_passwords)
```
Got error in the logs
```
        Traceback (most recent call last):
          File "/root/buildbot/lib/python3.4/site-packages/twisted/web/_auth/wrapper.py", line 133, in render
            return self._authorizedResource(request).render(request)
          File "/root/buildbot/lib/python3.4/site-packages/twisted/web/util.py", line 138, in render
            self.d.addCallback(self._cbChild, request).addErrback(
          File "/root/buildbot/lib/python3.4/site-packages/twisted/internet/defer.py", line 321, in addCallback
            callbackKeywords=kw)
          File "/root/buildbot/lib/python3.4/site-packages/twisted/internet/defer.py", line 310, in addCallbacks
            self._runCallbacks()
        --- <exception caught here> ---
          File "/root/buildbot/lib/python3.4/site-packages/twisted/internet/defer.py", line 653, in _runCallbacks
            current.result = callback(current.result, *args, **kw)
          File "/root/buildbot/lib/python3.4/site-packages/twisted/web/util.py", line 144, in _cbChild
            request.render(resource.getChildForRequest(child, request))
          File "/root/buildbot/lib/python3.4/site-packages/twisted/web/server.py", line 241, in render
            body = resrc.render(self)
          File "/root/buildbot/lib/python3.4/site-packages/twisted/web/_auth/wrapper.py", line 59, in render
            generateWWWAuthenticate(fact.scheme, challenge))
          File "/root/buildbot/lib/python3.4/site-packages/twisted/web/_auth/wrapper.py", line 48, in generateWWWAuthenticate
            l.append(networkString("%s=%s" % (k, quoteString(v))))
          File "/root/buildbot/lib/python3.4/site-packages/twisted/web/_auth/wrapper.py", line 52, in quoteString
            return '"%s"' % (s.replace('\\', '\\\\').replace('"', '\\"'),)
        builtins.TypeError: 'str' does not support the buffer interface
```

my guess is there is a need for a bytesToNativeString somewhere



Discussion:

Resolving patch:
diff --git a/master/buildbot/monkeypatches/__init__.py b/master/buildbot/monkeypatches/__init__.py
--- a/master/buildbot/monkeypatches/__init__.py
+++ b/master/buildbot/monkeypatches/__init__.py
@@ -17,6 +17,7 @@
 
 from twisted.python import util
 from builtins import int
+from future.utils import PY3
 
 
 def onlyOnce(fn):
@@ -43,6 +44,13 @@ def patch_python14653():
         python14653.patch()
 
 
+@onlyOnce
+def patch_twisted9127():
+    if PY3:
+        from buildbot.monkeypatches import twisted9127
+        twisted9127.patch()
+
+
 @onlyOnce
 def patch_testcase_timeout():
     import unittest
@@ -121,3 +129,4 @@ def patch_all(for_tests=False):
         patch_unittest_testcase()
 
     patch_python14653()
+    patch_twisted9127()
diff --git a/master/buildbot/monkeypatches/twisted9127.py b/master/buildbot/monkeypatches/twisted9127.py
new file mode 100644
--- /dev/null
+++ b/master/buildbot/monkeypatches/twisted9127.py
@@ -0,0 +1,53 @@
+# This file is part of Buildbot.  Buildbot is free software: you can
+# redistribute it and/or modify it under the terms of the GNU General Public
+# License as published by the Free Software Foundation, version 2.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# this program; if not, write to the Free Software Foundation, Inc., 51
+# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+#
+# Copyright Buildbot Team Members
+
+from __future__ import absolute_import
+from __future__ import print_function
+
+from twisted.python.compat import nativeString
+from twisted.python.compat import networkString
+
+
+# patch for http://twistedmatrix.com/trac/ticket/9127
+# unfortunately the impacted code is deeply inside render method, so we need to patch the whole
+# render method
+
+def render(self, request):
+    """
+    Send www-authenticate headers to the client
+    """
+    def generateWWWAuthenticate(scheme, challenge):
+        _l = []
+        for k, v in challenge.items():
+            _l.append(networkString("%s=%s" % (nativeString(k), quoteString(nativeString(v)))))
+        return b" ".join([scheme, b", ".join(_l)])
+
+    def quoteString(s):
+        return '"%s"' % (s.replace('\\', '\\\\').replace('"', '\\"'),)
+
+    request.setResponseCode(401)
+    for fact in self._credentialFactories:
+        challenge = fact.getChallenge(request)
+        request.responseHeaders.addRawHeader(
+            b'www-authenticate',
+            generateWWWAuthenticate(fact.scheme, challenge))
+    if request.method == b'HEAD':
+        return b''
+    return b'Unauthorized'
+
+
+def patch():
+    from twisted.web._auth.wrapper import UnauthorizedResource
+    UnauthorizedResource.render = render
diff --git a/master/buildbot/www/auth.py b/master/buildbot/www/auth.py
--- a/master/buildbot/www/auth.py
+++ b/master/buildbot/www/auth.py
@@ -30,6 +30,7 @@
 from twisted.web.resource import IResource
 from zope.interface import implementer
 
+from buildbot.util import bytes2NativeString
 from buildbot.util import config
 from buildbot.www import resource
 
@@ -158,8 +159,8 @@ class HTPasswdAuth(TwistedICredAuthBase):
     def __init__(self, passwdFile, **kwargs):
         TwistedICredAuthBase.__init__(
             self,
-            [DigestCredentialFactory("md5", "buildbot"),
-             BasicCredentialFactory("buildbot")],
+            [DigestCredentialFactory(b"md5", b"buildbot"),
+             BasicCredentialFactory(b"buildbot")],
             [FilePasswordDB(passwdFile)],
             **kwargs)
 
@@ -169,12 +170,17 @@ class UserPasswordAuth(TwistedICredAuthBase):
     def __init__(self, users, **kwargs):
         TwistedICredAuthBase.__init__(
             self,
-            [DigestCredentialFactory("md5", "buildbot"),
-             BasicCredentialFactory("buildbot")],
+            [DigestCredentialFactory(b"md5", b"buildbot"),
+             BasicCredentialFactory(b"buildbot")],
             [InMemoryUsernamePasswordDatabaseDontUse(**dict(users))],
             **kwargs)
 
 
+def _redirect(master, request):
+    url = request.args.get("redirect", ["/"])[0]
+    return resource.Redirect(master.config.buildbotURL + "#" + url)
+
+
 class PreAuthenticatedLoginResource(LoginResource):
     # a LoginResource which is already authenticated via a
     # HTTPAuthSessionWrapper
@@ -186,8 +192,9 @@ def __init__(self, master, username):
     @defer.inlineCallbacks
     def renderLogin(self, request):
         session = request.getSession()
-        session.user_info = dict(username=self.username)
+        session.user_info = dict(username=bytes2NativeString(self.username))
         yield self.master.www.auth.updateUserInfo(request)
+        raise _redirect(self.master, request)
 
 
 class LogoutResource(resource.Resource):
@@ -196,4 +203,5 @@ def render_GET(self, request):
         session = request.getSession()
         session.expire()
         session.updateSession(request)
+        request.redirect(_redirect(self.master, request).url)
         return b''


Test patch:
diff --git a/master/buildbot/test/unit/test_www_auth.py b/master/buildbot/test/unit/test_www_auth.py
--- a/master/buildbot/test/unit/test_www_auth.py
+++ b/master/buildbot/test/unit/test_www_auth.py
@@ -197,7 +197,7 @@ def updateUserInfo(request):
         self.auth.updateUserInfo = mock.Mock(side_effect=updateUserInfo)
 
         res = yield self.render_resource(self.rsrc, b'/auth/login')
-        self.assertEqual(res, b'')
+        self.assertEqual(res, {'redirected': 'h:/a/b/#/'})
         self.assertFalse(self.auth.maybeAutoLogin.called)
         self.auth.updateUserInfo.assert_called_with(mock.ANY)
         self.assertEqual(self.master.session.user_info,
@@ -214,5 +214,5 @@ def setUp(self):
     def test_render(self):
         self.master.session.expire = mock.Mock()
         res = yield self.render_resource(self.rsrc, b'/auth/logout')
-        self.assertEqual(res, b'')
+        self.assertEqual(res, {'redirected': 'h:/a/b/#/'})
         self.master.session.expire.assert_called_with()