[CalendarServer-changes] [11403] CalendarServer/trunk
source_changes at macosforge.org
source_changes at macosforge.org
Tue Jun 25 18:07:36 PDT 2013
Revision: 11403
http://trac.calendarserver.org//changeset/11403
Author: sagen at apple.com
Date: 2013-06-25 18:07:36 -0700 (Tue, 25 Jun 2013)
Log Message:
-----------
Server adminstration agent tests and authentication
Modified Paths:
--------------
CalendarServer/trunk/calendarserver/tools/agent.py
CalendarServer/trunk/contrib/launchd/agent.plist
Added Paths:
-----------
CalendarServer/trunk/calendarserver/tools/test/test_agent.py
Modified: CalendarServer/trunk/calendarserver/tools/agent.py
===================================================================
--- CalendarServer/trunk/calendarserver/tools/agent.py 2013-06-25 18:48:16 UTC (rev 11402)
+++ CalendarServer/trunk/calendarserver/tools/agent.py 2013-06-26 01:07:36 UTC (rev 11403)
@@ -19,17 +19,30 @@
import cStringIO
import socket
+
+from calendarserver.tap.util import getRootResource
from twext.python.plistlib import readPlistFromString, writePlistToString
-from calendarserver.tap.util import getRootResource
from twisted.application.internet import StreamServerEndpointService
+from twisted.cred.checkers import ICredentialsChecker
+from twisted.cred.credentials import IUsernameHashedPassword
+from twisted.cred.error import UnauthorizedLogin
+from twisted.cred.portal import IRealm, Portal
+from twisted.internet.defer import inlineCallbacks, returnValue, succeed, fail
from twisted.internet.endpoints import AdoptedStreamServerEndpoint
+from twisted.internet.protocol import Factory
from twisted.protocols import amp
-from twisted.internet.protocol import Factory
-from twisted.internet.defer import inlineCallbacks, returnValue
+from twisted.web.guard import HTTPAuthSessionWrapper, DigestCredentialFactory
+from twisted.web.resource import IResource, Resource, ForbiddenResource
+from twisted.web.server import Site, NOT_DONE_YET
+from zope.interface import implements
+
# TODO, implement this:
# from launchd import getLaunchdSocketFds
+def getLaunchdSocketFds():
+ pass
+
# For the sample client, below:
from twisted.internet import reactor
from twisted.internet.protocol import ClientCreator
@@ -37,13 +50,219 @@
from twext.python.log import Logger
log = Logger()
+
"""
A service spawned on-demand by launchd, meant to handle configuration requests
from Server.app. When a request comes in on the socket specified in the launchd
agent.plist, launchd will run "caldavd -t Agent" which ends up creating this
-service. AMP protocol commands sent to this socket are passed to gateway.Runner.
+service. Requests are made using HTTP POSTS to /gateway, and are authenticated
+by OpenDirectory.
"""
+class DirectoryServiceChecker:
+ """
+ A checker that knows how to ask OpenDirectory to authenticate via Digest
+ """
+ implements(ICredentialsChecker)
+
+ credentialInterfaces = (IUsernameHashedPassword,)
+
+ from calendarserver.platform.darwin.od import opendirectory
+ directoryModule = opendirectory
+
+ def __init__(self, node):
+ """
+ @param node: the name of the OpenDirectory node to use, e.g. /Local/Default
+ """
+ self.node = node
+ self.directory = self.directoryModule.odInit(node)
+
+ def requestAvatarId(self, credentials):
+ record = self.directoryModule.getUserRecord(self.directory, credentials.username)
+
+ if record is not None:
+ try:
+ if "algorithm" not in credentials.fields:
+ credentials.fields["algorithm"] = "md5"
+
+ challenge = 'Digest realm="%(realm)s", nonce="%(nonce)s", algorithm=%(algorithm)s' % credentials.fields
+
+ response = (
+ 'Digest username="%(username)s", '
+ 'realm="%(realm)s", '
+ 'nonce="%(nonce)s", '
+ 'uri="%(uri)s", '
+ 'response="%(response)s",'
+ 'algorithm=%(algorithm)s'
+ ) % credentials.fields
+
+ except KeyError as e:
+ log.error(
+ "OpenDirectory (node=%s) error while performing digest authentication for user %s: "
+ "missing digest response field: %s in: %s"
+ % (self.node, credentials.username, e, credentials.fields)
+ )
+ return fail(UnauthorizedLogin())
+
+ try:
+ if self.directoryModule.authenticateUserDigest(self.directory,
+ self.node,
+ credentials.username,
+ challenge,
+ response,
+ credentials.method
+ ):
+ return succeed(credentials.username)
+ else:
+ log.error("Failed digest auth with response: %s" % (response,))
+ return fail(UnauthorizedLogin())
+ except self.directoryModule.ODNSerror as e:
+ log.error(
+ "OpenDirectory error while performing digest authentication for user %s: %s"
+ % (credentials.username, e)
+ )
+ return fail(UnauthorizedLogin())
+
+ else:
+ return fail(UnauthorizedLogin())
+
+
+class CustomDigestCredentialFactory(DigestCredentialFactory):
+ """
+ DigestCredentialFactory without qop, to interop with OD.
+ """
+
+ def getChallenge(self, address):
+ result = DigestCredentialFactory.getChallenge(self, address)
+ del result["qop"]
+ return result
+
+
+class AgentRealm(object):
+ """
+ Only allow a specified list of avatar IDs to access the site
+ """
+ implements(IRealm)
+
+ def __init__(self, root, allowedAvatarIds):
+ """
+ @param root: The root resource of the site
+ @param allowedAvatarIds: The list of IDs to allow access to
+ """
+ self.root = root
+ self.allowedAvatarIds = allowedAvatarIds
+
+ def requestAvatar(self, avatarId, mind, *interfaces):
+ if IResource in interfaces:
+ if avatarId in self.allowedAvatarIds:
+ return (IResource, self.root, lambda: None)
+ else:
+ return (IResource, ForbiddenResource(), lambda: None)
+
+ raise NotImplementedError()
+
+
+
+class AgentGatewayResource(Resource):
+ """
+ The gateway resource which forwards incoming requests through gateway.Runner.
+ """
+ isLeaf = True
+
+ def __init__(self, store, davRootResource, directory):
+ """
+ @param store: an already opened store
+ @param davRootResource: the root resource, required for principal
+ operations
+ @param directory: a directory service
+ """
+ Resource.__init__(self)
+ self.store = store
+ self.davRootResource = davRootResource
+ self.directory = directory
+
+ def render_POST(self, request):
+ """
+ Take the body of the POST request and feed it to gateway.Runner();
+ return the result as the response body.
+ """
+
+ def onSuccess(result, output):
+ txt = output.getvalue()
+ output.close()
+ request.write(txt)
+ request.finish()
+
+ def onError(failure):
+ message = failure.getErrorMessage()
+ tbStringIO = cStringIO.StringIO()
+ failure.printTraceback(file=tbStringIO)
+ tbString = tbStringIO.getvalue()
+ tbStringIO.close()
+ error = {
+ "Error" : message,
+ "Traceback" : tbString,
+ }
+ log.error("command failed %s" % (failure,))
+ request.write(writePlistToString(error))
+ request.finish()
+
+ from calendarserver.tools.gateway import Runner
+ body = request.content.read()
+ command = readPlistFromString(body)
+ output = cStringIO.StringIO()
+ runner = Runner(self.davRootResource, self.directory, self.store,
+ [command], output=output)
+ d = runner.run()
+ d.addCallback(onSuccess, output)
+ d.addErrback(onError)
+ return NOT_DONE_YET
+
+
+
+
+
+def makeAgentService(store):
+ """
+ Returns a service which will process GatewayAMPCommands, using a socket
+ file descripter acquired by launchd
+
+ @param store: an already opened store
+ @returns: service
+ """
+ from twisted.internet import reactor
+
+ sockets = getLaunchdSocketFds()
+ fd = sockets["AgentSocket"][0]
+
+ family = socket.AF_INET
+ endpoint = AdoptedStreamServerEndpoint(reactor, fd, family)
+
+ from twistedcaldav.config import config
+ davRootResource = getRootResource(config, store)
+ directory = davRootResource.getDirectory()
+
+ root = Resource()
+ root.putChild("gateway", AgentGatewayResource(store,
+ davRootResource, directory))
+
+ realmName = "/Local/Default"
+ portal = Portal(AgentRealm(root, ["com.apple.calendarserver"]),
+ [DirectoryServiceChecker(realmName)])
+ credentialFactory = CustomDigestCredentialFactory("md5", realmName)
+ wrapper = HTTPAuthSessionWrapper(portal, [credentialFactory])
+
+ site = Site(wrapper)
+
+ return StreamServerEndpointService(endpoint, site)
+
+
+
+
+#
+# Alternate implementation using AMP instead of HTTP
+#
+
class GatewayAMPCommand(amp.Command):
"""
A command to be executed by gateway.Runner
@@ -119,33 +338,9 @@
self.directory)
-def makeAgentService(store):
- """
- Returns a service which will process GatewayAMPCommands, using a socket
- file descripter acquired by launchd
- @param store: an already opened store
- @returns: service
- """
- from twisted.internet import reactor
-
- # TODO: remove this
- def getLaunchdSocketFds():
- return {}
-
- # TODO: implement this
- sockets = getLaunchdSocketFds()
- fd = sockets["AgentSocket"][0]
-
- # TODO: use UNIX socket
- family = socket.AF_INET
- endpoint = AdoptedStreamServerEndpoint(reactor, fd, family)
- return StreamServerEndpointService(endpoint, GatewayAMPFactory(store))
-
-
-
#
-# A test client
+# A test AMP client
#
command = """<?xml version="1.0" encoding="UTF-8"?>
@@ -159,8 +354,7 @@
def getList():
creator = ClientCreator(reactor, amp.AMP)
- # TODO: use UNIX socket
- d = creator.connectTCP('127.0.0.1', 12345)
+ d = creator.connectTCP('sagen.apple.com', 62308)
def connected(ampProto):
return ampProto.callRemote(GatewayAMPCommand, command=command)
Added: CalendarServer/trunk/calendarserver/tools/test/test_agent.py
===================================================================
--- CalendarServer/trunk/calendarserver/tools/test/test_agent.py (rev 0)
+++ CalendarServer/trunk/calendarserver/tools/test/test_agent.py 2013-06-26 01:07:36 UTC (rev 11403)
@@ -0,0 +1,147 @@
+##
+# Copyright (c) 2013 Apple Inc. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+##
+
+from calendarserver.tools.agent import AgentRealm
+from calendarserver.tools.agent import CustomDigestCredentialFactory
+from calendarserver.tools.agent import DirectoryServiceChecker
+from twistedcaldav.test.util import TestCase
+from twisted.internet.defer import inlineCallbacks
+from twisted.cred.error import UnauthorizedLogin
+from twisted.web.resource import IResource
+from twisted.web.resource import ForbiddenResource
+
+
+
+
+class AgentTestCase(TestCase):
+
+ def test_CustomDigestCredentialFactory(self):
+ f = CustomDigestCredentialFactory("md5", "/Local/Default")
+ challenge = f.getChallenge(FakeRequest())
+ self.assertTrue("qop" not in challenge)
+ self.assertEquals(challenge["algorithm"], "md5")
+ self.assertEquals(challenge["realm"], "/Local/Default")
+
+ @inlineCallbacks
+ def test_DirectoryServiceChecker(self):
+ c = DirectoryServiceChecker("/Local/Default")
+ fakeOpenDirectory = FakeOpenDirectory()
+ c.directoryModule = fakeOpenDirectory
+
+ fields = {
+ "username" : "foo",
+ "realm" : "/Local/Default",
+ "nonce" : 1,
+ "uri" : "/gateway",
+ "response" : "abc",
+ "algorithm" : "md5",
+ }
+ creds = FakeCredentials("foo", fields)
+
+ # Record does not exist:
+ fakeOpenDirectory.returnThisRecord(None)
+ try:
+ yield c.requestAvatarId(creds)
+ except UnauthorizedLogin:
+ pass
+ else:
+ self.fail("Didn't raise UnauthorizedLogin")
+
+
+ # Record exists, but invalid credentials
+ fakeOpenDirectory.returnThisRecord("fooRecord")
+ fakeOpenDirectory.returnThisAuthResponse(False)
+ try:
+ yield c.requestAvatarId(creds)
+ except UnauthorizedLogin:
+ pass
+ else:
+ self.fail("Didn't raise UnauthorizedLogin")
+
+
+ # Record exists, valid credentials
+ fakeOpenDirectory.returnThisRecord("fooRecord")
+ fakeOpenDirectory.returnThisAuthResponse(True)
+ avatar = (yield c.requestAvatarId(creds))
+ self.assertEquals(avatar, "foo")
+
+
+ # Record exists, but missing fields in credentials
+ del creds.fields["nonce"]
+ fakeOpenDirectory.returnThisRecord("fooRecord")
+ fakeOpenDirectory.returnThisAuthResponse(False)
+ try:
+ yield c.requestAvatarId(creds)
+ except UnauthorizedLogin:
+ pass
+ else:
+ self.fail("Didn't raise UnauthorizedLogin")
+
+
+ def test_AgentRealm(self):
+ realm = AgentRealm("root", ["abc"])
+
+ # Valid avatar
+ interface, resource, ignored = realm.requestAvatar("abc", None, IResource)
+ self.assertEquals(resource, "root")
+
+ # Not allowed avatar
+ interface, resource, ignored = realm.requestAvatar("def", None, IResource)
+ self.assertTrue(isinstance(resource, ForbiddenResource))
+
+ # Interface unhandled
+ try:
+ realm.requestAvatar("def", None, None)
+ except NotImplementedError:
+ pass
+ else:
+ self.fail("Didn't raise NotImplementedError")
+
+
+
+
+class FakeRequest(object):
+
+ def getClientIP(self):
+ return "127.0.0.1"
+
+
+
+class FakeOpenDirectory(object):
+
+ def returnThisRecord(self, response):
+ self.recordResponse = response
+
+ def getUserRecord(self, ignored, username):
+ return self.recordResponse
+
+ def returnThisAuthResponse(self, response):
+ self.authResponse = response
+
+ def authenticateUserDigest(self, ignored, node, username, challenge, response,
+ method):
+ return self.authResponse
+
+ ODNSerror = "Error"
+
+
+
+class FakeCredentials(object):
+
+ def __init__(self, username, fields):
+ self.username = username
+ self.fields = fields
+ self.method = "POST"
Modified: CalendarServer/trunk/contrib/launchd/agent.plist
===================================================================
--- CalendarServer/trunk/contrib/launchd/agent.plist 2013-06-25 18:48:16 UTC (rev 11402)
+++ CalendarServer/trunk/contrib/launchd/agent.plist 2013-06-26 01:07:36 UTC (rev 11403)
@@ -46,19 +46,12 @@
<key>AgentSocket</key>
<dict>
<key>SockServiceName</key>
- <string>12345</string>
+ <string>62308</string>
+ <key>SockNodeName</key>
+ <string>127.0.0.1</string>
<key>SockFamily</key>
<string>IPv4</string>
</dict>
- <!--
- <key>AgentSocket</key>
- <dict>
- <key>SockFamily</key>
- <string>UNIX</string>
- <key>SockPathName</key>
- <string>/some/path/org.calendarserver.agent.sock</string>
- </dict>
- -->
</dict>
<key>HardResourceLimits</key>
@@ -73,9 +66,6 @@
<integer>12000</integer>
</dict>
- <key>PreventsSleep</key>
- <true/>
-
<key>StandardOutPath</key>
<string>/var/log/caldavd/agent.log</string>
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://lists.macosforge.org/pipermail/calendarserver-changes/attachments/20130625/15c57062/attachment-0001.html>
More information about the calendarserver-changes
mailing list