Skip to content

Commit

Permalink
www.auth implementation
Browse files Browse the repository at this point in the history
This implements the authentication part of the problem, and a nice
avatar interface, which can deliver corporates photos, as well as gravatar

Everything is integrated when possible with twisted.web.guard,
which is probably the less user friendly API I have ever seen.
  • Loading branch information
Pierre Tardy committed Mar 19, 2014
1 parent 284503d commit 5dc80fb
Show file tree
Hide file tree
Showing 21 changed files with 846 additions and 1,597 deletions.
8 changes: 6 additions & 2 deletions master/buildbot/config.py
Expand Up @@ -25,6 +25,8 @@
from buildbot import util
from buildbot.revlinks import default_revlink_matcher
from buildbot.util import safeTranslate
from buildbot.www import auth
from buildbot.www import avatar
from twisted.application import service
from twisted.internet import defer
from twisted.python import failure
Expand Down Expand Up @@ -110,7 +112,9 @@ def __init__(self):
self.www = dict(
port=None,
url='http://localhost:8080/',
plugins=dict()
plugins=dict(),
auth=auth.NoAuth(),
avatar_methods=avatar.AvatarGravatar()
)

_known_config_keys = set([
Expand Down Expand Up @@ -558,7 +562,7 @@ def load_www(self, filename, config_dict):
www_cfg = config_dict['www']
allowed = set(['port', 'url', 'debug', 'json_cache_seconds',
'rest_minimum_version', 'allowed_origins', 'jsonp',
'plugins'])
'plugins', 'auth', 'avatar_methods'])
unknown = set(www_cfg.iterkeys()) - allowed
if unknown:
error("unknown www configuration parameter(s) %s" %
Expand Down
41 changes: 41 additions & 0 deletions master/buildbot/test/unit/test_www_auth.py
@@ -0,0 +1,41 @@
# 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

import mock
import re

from buildbot.test.fake import endpoint
from buildbot.test.util import compat
from buildbot.test.util import www
from buildbot.util import json
from buildbot.www import auth
from twisted.internet import defer
from twisted.trial import unittest


class SessionConfigResource(www.WwwTestMixin, unittest.TestCase):

@defer.inlineCallbacks
def test_render(self):
master = self.make_master(url='h:/a/b/', auth=auth.Auth())
rsrc = auth.SessionConfigResource(master)
rsrc.reconfigResource(master.config)
res = yield self.render_resource(rsrc, '/')
exp = 'this.config = {"url": "h:/a/b/", "user": {"anonymous": true}, "auth": "auth", "port": null}'
self.assertEqual(res, exp)
master.session.user_infos = dict(name="me", email="me@me.org")
res = yield self.render_resource(rsrc, '/')
exp = 'this.config = {"url": "h:/a/b/", "user": {"email": "me@me.org", "name": "me"}, "auth": "auth", "port": null}'
self.assertEqual(res, exp)
12 changes: 11 additions & 1 deletion master/buildbot/test/util/www.py
Expand Up @@ -27,6 +27,10 @@
from uuid import uuid1


class FakeSession(object):
pass


class FakeRequest(object):
written = ''
finished = False
Expand All @@ -40,7 +44,6 @@ def __init__(self, path=None):
self.headers = {}
self.input_headers = {}
self.prepath = []

x = path.split('?', 1)
if len(x) == 1:
self.path = path
Expand Down Expand Up @@ -84,6 +87,9 @@ def finished(res):
return res
return d

def getSession(self):
return self.session


class RequiresWwwMixin(object):
# mix this into a TestCase to skip if buildbot-www is not installed
Expand All @@ -101,14 +107,18 @@ class WwwTestMixin(RequiresWwwMixin):

def make_master(self, **kwargs):
master = fakemaster.make_master(wantData=True, testcase=self)
self.master = master
master.www = mock.Mock() # to handle the resourceNeedsReconfigs call
cfg = dict(url='//', port=None)
cfg.update(kwargs)
master.config.www = cfg
self.master.session = FakeSession()

return master

def make_request(self, path=None, method='GET'):
self.request = FakeRequest(path)
self.request.session = self.master.session
self.request.method = method
return self.request

Expand Down
225 changes: 225 additions & 0 deletions master/buildbot/www/auth.py
@@ -0,0 +1,225 @@
# 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

import re

from zope.interface import implements

from buildbot.util import json
from buildbot.www import resource

from twisted.cred.checkers import FilePasswordDB
from twisted.cred.checkers import InMemoryUsernamePasswordDatabaseDontUse
from twisted.cred.portal import IRealm
from twisted.cred.portal import Portal
from twisted.internet import defer
from twisted.web.error import Error
from twisted.web.guard import BasicCredentialFactory
from twisted.web.guard import DigestCredentialFactory
from twisted.web.guard import HTTPAuthSessionWrapper
from twisted.web.resource import IResource


class AuthBase(resource.ConfiguredBase):
name = "auth"

def __init__(self, userInfos=None):
if userInfos is None:
userInfos = UserInfosBase()
self.userInfos = userInfos

def maybeAutoLogin(self, request):
return defer.succeed(False)

def authenticateViaLogin(self, request):
raise Error(501, "not implemented")

def getLoginResource(self, master):
return LoginResource(master)

@defer.inlineCallbacks
def getUserInfos(self, request):
session = request.getSession()
if self.userInfos is not None:
infos = yield self.userInfos.getUserInfos(session.user_infos['username'])
session.user_infos.update(infos)


class UserInfosBase(resource.ConfiguredBase):
name = "noinfo"

def getUserInfos(self, username):
return defer.succeed({'email': username})


class NoAuth(AuthBase):
name = "noauth"


class RemoteUserAuth(AuthBase):
name = "remoteuserauth"
header = "REMOTE_USER"
headerRegex = re.compile(r"(?P<username>[^ @]+)@(?P<realm>[^ @]+)")

def __init__(self, header=None, headerRegex=None, **kwargs):
AuthBase.__init__(self, **kwargs)
if header is not None:
self.header = header
if headerRegex is not None:
self.headerRegex = headerRegex

@defer.inlineCallbacks
def maybeAutoLogin(self, request):
header = request.getHeader(self.header)
res = self.headerRegex.match(header)
if res is None:
raise Error(403, "http header does not match regex %s %s" % (header,
self.headerRegex.pattern))
session = request.getSession()
if not hasattr(session, "user_infos"):
session.user_infos = dict(res.groupdict())
yield self.getUserInfos(request)
defer.returnValue(True)

def authenticateViaLogin(self, request):
raise Error(403, "should authenticate via reverse proxy")


class authRealm(object):
implements(IRealm)

def __init__(self, master, auth):
self.auth = auth
self.master = master

def requestAvatar(self, avatarId, mind, *interfaces):
if IResource in interfaces:
return (IResource,
PreAuthenticatedLoginResource(self.master, self.auth, avatarId),
lambda: None)
raise NotImplementedError()


class TwistedICredAuthBase(AuthBase):
name = "icredauth"

def __init__(self, credentialFactories, checkers, **kwargs):
AuthBase.__init__(self, **kwargs)
self.credentialFactories = credentialFactories
self.checkers = checkers

def getLoginResource(self, master):
return HTTPAuthSessionWrapper(Portal(authRealm(master, self), self.checkers),
self.credentialFactories)


class HTPasswdAuth(TwistedICredAuthBase):

def __init__(self, passwdFile, **kwargs):
TwistedICredAuthBase.__init__(
self,
[DigestCredentialFactory("md5", "buildbot"), BasicCredentialFactory("buildbot")],
[FilePasswordDB(passwdFile)],
**kwargs)


class BasicAuth(TwistedICredAuthBase):

def __init__(self, users, **kwargs):
TwistedICredAuthBase.__init__(
self,
[DigestCredentialFactory("md5", "buildbot"), BasicCredentialFactory("buildbot")],
[InMemoryUsernamePasswordDatabaseDontUse(**users)],
**kwargs)


class SessionConfigResource(resource.Resource):
# enable reconfigResource calls
needsReconfig = True

def reconfigResource(self, new_config):
self.config = new_config.www

def render_GET(self, request):
return self.asyncRenderHelper(request, self.renderConfig)

@defer.inlineCallbacks
def renderConfig(self, request):
config = {}
request.setHeader("content-type", 'text/javascript')
request.setHeader("Cache-Control", "public;max-age=0")

session = request.getSession()
try:
yield self.config['auth'].maybeAutoLogin(request)
except Error, e:
config["on_load_warning"] = e.message

if hasattr(session, "user_infos"):
config.update({"user": session.user_infos})
else:
config.update({"user": {"anonymous": True}})
config.update(self.config)

def toJson(obj):
if isinstance(obj, object) and hasattr(obj, "getConfig"):
return obj.getConfig(request)
defer.returnValue("this.config = " + json.dumps(config, default=toJson))


class LoginResource(resource.Resource):
# enable reconfigResource calls
needsReconfig = True

def reconfigResource(self, new_config):
self.auth = new_config.www['auth']

def render_GET(self, request):
return self.asyncRenderHelper(request, self.renderLogin)

@defer.inlineCallbacks
def renderLogin(self, request):
yield self.auth.authenticateViaLogin(request)
defer.returnValue("")


class PreAuthenticatedLoginResource(LoginResource):
# a LoginResource, which is already authenticated via a HTTPAuthSessionWrapper
# disable reconfigResource calls
needsReconfig = False

def __init__(self, master, auth, username):
LoginResource.__init__(self, master)
self.auth = auth
self.username = username

@defer.inlineCallbacks
def renderLogin(self, request):
session = request.getSession()
session.user_infos = dict(username=self.username)
yield self.auth.getUserInfos(request)


class LogoutResource(resource.Resource):
# enable reconfigResource calls
needsReconfig = True

def reconfigResource(self, new_config):
self.auth = new_config.www['auth']

def render_GET(self, request):
session = request.getSession()
session.expire()
return ""

0 comments on commit 5dc80fb

Please sign in to comment.