Revision: 4297 http://trac.macosforge.org/projects/calendarserver/changeset/4297 Author: sagen@apple.com Date: 2009-05-19 14:28:46 -0700 (Tue, 19 May 2009) Log Message: ----------- Mail gateway's /inbox now requires authentication Modified Paths: -------------- CalendarServer/trunk/calendarserver/tap/caldav.py CalendarServer/trunk/twistedcaldav/mail.py CalendarServer/trunk/twistedcaldav/scheduling/imip.py CalendarServer/trunk/twistedcaldav/util.py Modified: CalendarServer/trunk/calendarserver/tap/caldav.py =================================================================== --- CalendarServer/trunk/calendarserver/tap/caldav.py 2009-05-19 21:02:35 UTC (rev 4296) +++ CalendarServer/trunk/calendarserver/tap/caldav.py 2009-05-19 21:28:46 UTC (rev 4297) @@ -75,7 +75,7 @@ from twistedcaldav.static import CalendarHomeProvisioningFile from twistedcaldav.static import IScheduleInboxFile from twistedcaldav.static import TimezoneServiceFile -from twistedcaldav.mail import IMIPInboxResource +from twistedcaldav.mail import IMIPReplyInboxResource from twistedcaldav.timezones import TimezoneCache from twistedcaldav.upgrade import upgradeData from twistedcaldav.pdmonster import PDClientAddressWrapper @@ -339,7 +339,7 @@ principalResourceClass = DirectoryPrincipalProvisioningResource calendarResourceClass = CalendarHomeProvisioningFile iScheduleResourceClass = IScheduleInboxFile - imipResourceClass = IMIPInboxResource + imipResourceClass = IMIPReplyInboxResource timezoneServiceResourceClass = TimezoneServiceFile webCalendarResourceClass = WebCalendarResource webAdminResourceClass = WebAdminResource Modified: CalendarServer/trunk/twistedcaldav/mail.py =================================================================== --- CalendarServer/trunk/twistedcaldav/mail.py 2009-05-19 21:02:35 UTC (rev 4296) +++ CalendarServer/trunk/twistedcaldav/mail.py 2009-05-19 21:28:46 UTC (rev 4297) @@ -20,36 +20,46 @@ """ from __future__ import with_statement +from calendarserver.provision.root import RootResource + from email.mime.image import MIMEImage from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from twisted.application import internet, service +from twisted.cred.portal import Portal from twisted.internet import protocol, defer, ssl, reactor +from twisted.internet.address import IPv4Address from twisted.internet.defer import inlineCallbacks, returnValue, succeed from twisted.mail import pop3client, imap4 from twisted.mail.smtp import messageid, rfc822date, ESMTPSenderFactory from twisted.plugin import IPlugin -from twisted.python.usage import Options, UsageError from twisted.python import failure -from twisted.web import resource, server, client -from twisted.web2 import responsecode +from twisted.python.reflect import namedClass +from twisted.python.usage import Options, UsageError +from twisted.web import client +from twisted.web2 import resource, server, responsecode +from twisted.web2.channel.http import HTTPFactory +from twisted.web2.dav import auth from twisted.web2.dav import davxml from twisted.web2.dav.noneprops import NonePropertyStore from twisted.web2.http import Response, HTTPError from twisted.web2.http_headers import MimeType -from twistedcaldav.directory.digest import QopDigestCredentialFactory from twistedcaldav import ical, caldavxml +from twistedcaldav import memcachepool from twistedcaldav.config import config, defaultConfig, defaultConfigFile +from twistedcaldav.directory.digest import QopDigestCredentialFactory +from twistedcaldav.directory.principal import DirectoryPrincipalProvisioningResource +from twistedcaldav.directory.util import NotFilePath from twistedcaldav.ical import Property +from twistedcaldav.localization import translationTo from twistedcaldav.log import Logger, LoggingMixIn -from twistedcaldav.directory.util import NotFilePath -from twistedcaldav.scheduling.scheduler import IMIPScheduler from twistedcaldav.scheduling.cuaddress import normalizeCUAddr -from twistedcaldav.static import CalDAVFile, deliverSchedulePrivilegeSet +from twistedcaldav.scheduling.scheduler import IMIPScheduler from twistedcaldav.sql import AbstractSQLDatabase -from twistedcaldav.localization import translationTo +from twistedcaldav.static import CalDAVFile, deliverSchedulePrivilegeSet +from twistedcaldav.util import AuthorizedHTTPGetter from zope.interface import implements @@ -57,8 +67,6 @@ import email.utils import os import uuid -from hashlib import md5, sha1 -import base64 try: from cStringIO import StringIO @@ -251,22 +259,6 @@ response.headers.setHeader("content-type", MimeType("text", "html")) return response - @inlineCallbacks - def http_POST(self, request): - """ - The IMIP delivery POST method. - """ - - # Check authentication and access controls - yield self.authorize(request, (caldavxml.ScheduleDeliver(),)) - - # Inject using the IMIPScheduler. - scheduler = IMIPScheduler(request, self) - - # Do the POST processing treating this as a non-local schedule - result = (yield scheduler.doSchedulingViaPOST(use_request_headers=True)) - returnValue(result.response()) - ## # File ## @@ -299,230 +291,54 @@ return succeed(deliverSchedulePrivilegeSet) +class IMIPReplyInboxResource(IMIPInboxResource): + @inlineCallbacks + def http_POST(self, request): + """ + The IMIP reply POST method (inbound) + """ -algorithms = { - 'md5': md5, - 'md5-sess': md5, - 'sha': sha1, -} + # Check authentication and access controls + yield self.authorize(request, (caldavxml.ScheduleDeliver(),)) -# DigestCalcHA1 -def calcHA1( - pszAlg, - pszUserName, - pszRealm, - pszPassword, - pszNonce, - pszCNonce, - preHA1=None -): - """ - @param pszAlg: The name of the algorithm to use to calculate the digest. - Currently supported are md5 md5-sess and sha. + # Inject using the IMIPScheduler. + scheduler = IMIPScheduler(request, self) - @param pszUserName: The username - @param pszRealm: The realm - @param pszPassword: The password - @param pszNonce: The nonce - @param pszCNonce: The cnonce + # Do the POST processing treating this as a non-local schedule + result = (yield scheduler.doSchedulingViaPOST(use_request_headers=True)) + returnValue(result.response()) - @param preHA1: If available this is a str containing a previously - calculated HA1 as a hex string. If this is given then the values for - pszUserName, pszRealm, and pszPassword are ignored. - """ - if (preHA1 and (pszUserName or pszRealm or pszPassword)): - raise TypeError(("preHA1 is incompatible with the pszUserName, " - "pszRealm, and pszPassword arguments")) +class IMIPInvitationInboxResource(IMIPInboxResource): - if preHA1 is None: - # We need to calculate the HA1 from the username:realm:password - m = algorithms[pszAlg]() - m.update(pszUserName) - m.update(":") - m.update(pszRealm) - m.update(":") - m.update(pszPassword) - HA1 = m.digest() - else: - # We were given a username:realm:password - HA1 = preHA1.decode('hex') + def __init__(self, parent, mailer): + super(IMIPInvitationInboxResource, self).__init__(parent) + self.mailer = mailer - if pszAlg == "md5-sess": - m = algorithms[pszAlg]() - m.update(HA1) - m.update(":") - m.update(pszNonce) - m.update(":") - m.update(pszCNonce) - HA1 = m.digest() + @inlineCallbacks + def http_POST(self, request): + """ + The IMIP invitation POST method (outbound) + """ - return HA1.encode('hex') + # Check authentication and access controls + yield self.authorize(request, (caldavxml.ScheduleDeliver(),)) -# DigestCalcResponse -def calcResponse( - HA1, - algo, - pszNonce, - pszNonceCount, - pszCNonce, - pszQop, - pszMethod, - pszDigestUri, - pszHEntity, -): - m = algorithms[algo]() - m.update(pszMethod) - m.update(":") - m.update(pszDigestUri) - if pszQop == "auth-int": - m.update(":") - m.update(pszHEntity) - HA2 = m.digest().encode('hex') + # Compute token, add to db, generate email and send it + calendar = (yield ical.Component.fromIStream(request.stream)) + originator = request.headers.getRawHeaders("originator")[0] + recipient = request.headers.getRawHeaders("recipient")[0] + language = config.Localization.Language - m = algorithms[algo]() - m.update(HA1) - m.update(":") - m.update(pszNonce) - m.update(":") - if pszNonceCount and pszCNonce and pszQop: - m.update(pszNonceCount) - m.update(":") - m.update(pszCNonce) - m.update(":") - m.update(pszQop) - m.update(":") - m.update(HA2) - respHash = m.digest().encode('hex') - return respHash + if not (yield self.mailer.outbound(originator, + recipient, calendar, language=language)): + returnValue(Response(code=responsecode.BAD_REQUEST)) -class Unauthorized(Exception): - pass + returnValue(Response(code=responsecode.OK)) -class AuthorizedHTTPGetter(client.HTTPPageGetter, LoggingMixIn): - def handleStatus_401(self): - self.quietLoss = 1 - self.transport.loseConnection() - - if not hasattr(self.factory, "username"): - self.factory.deferred.errback(failure.Failure(Unauthorized("Mail gateway not able to process reply; authentication required for calendar server"))) - return self.factory.deferred - - if hasattr(self.factory, "retried"): - self.factory.deferred.errback(failure.Failure(Unauthorized("Mail gateway not able to process reply; could not authenticate user %s with calendar server" % (self.factory.username,)))) - return self.factory.deferred - - self.factory.retried = True - - # self.log_debug("Got a 401 trying to inject [%s]" % (self.headers,)) - details = {} - basicAvailable = digestAvailable = False - wwwauth = self.headers.get("www-authenticate") - for item in wwwauth: - if item.startswith("basic "): - basicAvailable = True - if item.startswith("digest "): - digestAvailable = True - wwwauth = item[7:] - def unq(s): - if s[0] == s[-1] == '"': - return s[1:-1] - return s - parts = wwwauth.split(',') - for (k, v) in [p.split('=', 1) for p in parts]: - details[k.strip()] = unq(v.strip()) - - user = self.factory.username - pswd = self.factory.password - - if digestAvailable and details: - digest = calcResponse( - calcHA1( - details.get('algorithm'), - user, - details.get('realm'), - pswd, - details.get('nonce'), - details.get('cnonce') - ), - details.get('algorithm'), - details.get('nonce'), - details.get('nc'), - details.get('cnonce'), - details.get('qop'), - self.factory.method, - self.factory.url, - None - ) - - if details.get('qop'): - response = ( - 'Digest username="%s", realm="%s", nonce="%s", uri="%s", ' - 'response=%s, algorithm=%s, cnonce="%s", qop=%s, nc=%s' % - ( - user, - details.get('realm'), - details.get('nonce'), - self.factory.url, - digest, - details.get('algorithm'), - details.get('cnonce'), - details.get('qop'), - details.get('nc'), - ) - ) - else: - response = ( - 'Digest username="%s", realm="%s", nonce="%s", uri="%s", ' - 'response=%s, algorithm=%s' % - ( - user, - details.get('realm'), - details.get('nonce'), - self.factory.url, - digest, - details.get('algorithm'), - ) - ) - - self.factory.headers['Authorization'] = response - - if self.factory.scheme == 'https': - reactor.connectSSL(self.factory.host, self.factory.port, - self.factory, ssl.ClientContextFactory()) - else: - reactor.connectTCP(self.factory.host, self.factory.port, - self.factory) - # self.log_debug("Retrying with digest after 401") - - return self.factory.deferred - - elif basicAvailable: - basicauth = "%s:%s" % (user, pswd) - basicauth = "Basic " + base64.encodestring( basicauth ) - basicauth = basicauth.replace( "\n", "" ) - - self.factory.headers['Authorization'] = basicauth - - if self.factory.scheme == 'https': - reactor.connectSSL(self.factory.host, self.factory.port, - self.factory, ssl.ClientContextFactory()) - else: - reactor.connectTCP(self.factory.host, self.factory.port, - self.factory) - # self.log_debug("Retrying with basic after 401") - - return self.factory.deferred - - - else: - self.factory.deferred.errback(failure.Failure(Unauthorized("Mail gateway not able to process reply; calendar server returned 401 and doesn't support basic or digest"))) - return self.factory.deferred - - def injectMessage(organizer, attendee, calendar, msgId, reactor=None): if reactor is None: @@ -726,6 +542,16 @@ def makeService(self, options): + if config.Memcached.ClientEnabled: + memcachepool.installPool( + IPv4Address( + "TCP", + config.Memcached.BindAddress, + config.Memcached.Port, + ), + config.Memcached.MaxClients, + ) + multiService = service.MultiService() settings = config.Scheduling['iMIP'] @@ -747,7 +573,10 @@ client.setServiceParent(multiService) + + # Set up /inbox -- server POSTs to it to send out iMIP invites IScheduleService(settings, mailer).setServiceParent(multiService) + else: self.log_info("Mail Gateway Service not enabled") @@ -762,12 +591,43 @@ def __init__(self, settings, mailer): self.settings = settings self.mailer = mailer - root = resource.Resource() - root.putChild('', self.HomePage()) - root.putChild('inbox', self.IScheduleInbox(mailer)) - self.site = server.Site(root) - self.server = internet.TCPServer(settings['MailGatewayPort'], self.site) + directoryClass = namedClass(config.DirectoryService.type) + directory = directoryClass(config.DirectoryService.params) + + principalCollection = DirectoryPrincipalProvisioningResource( + "/principals/", + directory, + ) + + root = RootResource( + config.DocumentRoot, + principalCollections=(principalCollection,), + ) + + # Authenticated /inbox + credentialFactories = [] + portal = Portal(auth.DavRealm()) + portal.registerChecker(directory) + realm = directory.realmName or "" + schemeConfig = config.Authentication.Digest + digestCredentialFactory = QopDigestCredentialFactory( + schemeConfig["Algorithm"], + schemeConfig["Qop"], + realm, + ) + root.putChild('inbox', + auth.AuthenticationWrapper( + IMIPInvitationInboxResource(root, mailer), + portal, + (digestCredentialFactory,), + (auth.IPrincipal,), + ) + ) + self.factory = HTTPFactory(server.Site(root)) + self.server = internet.TCPServer(settings['MailGatewayPort'], + self.factory) + def startService(self): self.server.startService() @@ -775,45 +635,8 @@ self.server.stopService() - class HomePage(resource.Resource): - def render(self, request): - return """ - <html> - <head><title>ISchedule - IMIP Gateway</title></head> - <body>ISchedule - IMIP Gateway</body> - </html> - """ - class IScheduleInbox(resource.Resource): - def __init__(self, mailer): - resource.Resource.__init__(self) - self.mailer = mailer - - def render_GET(self, request): - return """ - <html> - <head><title>ISchedule Inbox</title></head> - <body>ISchedule Inbox</body> - </html> - """ - - def render_POST(self, request): - # Compute token, add to db, generate email and send it - calendar = ical.Component.fromString(request.content.read()) - headers = request.getAllHeaders() - language = config.Localization.Language - self.mailer.outbound(headers['originator'], headers['recipient'], - calendar, language=language) - - # TODO: what to return? - return """ - <html> - <head><title>ISchedule Inbox</title></head> - <body>ISchedule Inbox</body> - </html> - """ - class MailHandler(LoggingMixIn): def __init__(self, dataRoot=None): @@ -1059,10 +882,12 @@ def _success(result, msgId, fromAddr, toAddr): self.log_info("Mail gateway sent message %s from %s to %s" % (msgId, fromAddr, toAddr)) + return True def _failure(failure, msgId, fromAddr, toAddr): self.log_error("Mail gateway failed to send message %s from %s to %s (Reason: %s)" % (msgId, fromAddr, toAddr, failure.getErrorMessage())) + return False deferred = defer.Deferred() @@ -1080,6 +905,7 @@ reactor.connectTCP(settings['Server'], settings['Port'], factory) deferred.addCallback(_success, msgId, fromAddr, toAddr) deferred.addErrback(_failure, msgId, fromAddr, toAddr) + return deferred def getIconPath(self, details, canceled, language='en'): @@ -1507,7 +1333,7 @@ self.log_error("IMAP Error: %s" % (error,)) def ebAuthenticateFailed(self, reason): - self.log_info("IMAP authenticate failed for %s, trying login" % + self.log_debug("IMAP authenticate failed for %s, trying login" % (self.factory.settings["Username"],)) return self.login(self.factory.settings["Username"], self.factory.settings["Password"] Modified: CalendarServer/trunk/twistedcaldav/scheduling/imip.py =================================================================== --- CalendarServer/trunk/twistedcaldav/scheduling/imip.py 2009-05-19 21:02:35 UTC (rev 4296) +++ CalendarServer/trunk/twistedcaldav/scheduling/imip.py 2009-05-19 21:28:46 UTC (rev 4297) @@ -27,6 +27,7 @@ from twistedcaldav.caldavxml import caldav_namespace from twistedcaldav.config import config from twistedcaldav.log import Logger +from twistedcaldav.util import AuthorizedHTTPGetter from twistedcaldav.scheduling.delivery import DeliveryService from twistedcaldav.scheduling.itip import iTIPRequestStatus @@ -98,6 +99,13 @@ } factory = client.HTTPClientFactory(url, method='POST', headers=headers, postdata=caldata, agent="CalDAV server") + + if config.Scheduling.iMIP.Username: + factory.username = config.Scheduling.iMIP.Username + factory.password = config.Scheduling.iMIP.Password + + factory.noisy = False + factory.protocol = AuthorizedHTTPGetter reactor.connectTCP(mailGatewayServer, mailGatewayPort, factory) return factory.deferred Modified: CalendarServer/trunk/twistedcaldav/util.py =================================================================== --- CalendarServer/trunk/twistedcaldav/util.py 2009-05-19 21:02:35 UTC (rev 4296) +++ CalendarServer/trunk/twistedcaldav/util.py 2009-05-19 21:28:46 UTC (rev 4297) @@ -19,6 +19,13 @@ import sys from subprocess import Popen, PIPE, STDOUT +from twisted.internet import ssl, reactor +from twisted.web import client +from twistedcaldav.log import Logger, LoggingMixIn +from twisted.python import failure +from hashlib import md5, sha1 +import base64 + ## # getNCPU ## @@ -161,3 +168,232 @@ else: error = "Keychain access utility ('security') not found" raise KeychainAccessError(error) + + + + +## +# Digest/Basic-capable HTTP GET factory +## + +algorithms = { + 'md5': md5, + 'md5-sess': md5, + 'sha': sha1, +} + +# DigestCalcHA1 +def calcHA1( + pszAlg, + pszUserName, + pszRealm, + pszPassword, + pszNonce, + pszCNonce, + preHA1=None +): + """ + @param pszAlg: The name of the algorithm to use to calculate the digest. + Currently supported are md5 md5-sess and sha. + + @param pszUserName: The username + @param pszRealm: The realm + @param pszPassword: The password + @param pszNonce: The nonce + @param pszCNonce: The cnonce + + @param preHA1: If available this is a str containing a previously + calculated HA1 as a hex string. If this is given then the values for + pszUserName, pszRealm, and pszPassword are ignored. + """ + + if (preHA1 and (pszUserName or pszRealm or pszPassword)): + raise TypeError(("preHA1 is incompatible with the pszUserName, " + "pszRealm, and pszPassword arguments")) + + if preHA1 is None: + # We need to calculate the HA1 from the username:realm:password + m = algorithms[pszAlg]() + m.update(pszUserName) + m.update(":") + m.update(pszRealm) + m.update(":") + m.update(pszPassword) + HA1 = m.digest() + else: + # We were given a username:realm:password + HA1 = preHA1.decode('hex') + + if pszAlg == "md5-sess": + m = algorithms[pszAlg]() + m.update(HA1) + m.update(":") + m.update(pszNonce) + m.update(":") + m.update(pszCNonce) + HA1 = m.digest() + + return HA1.encode('hex') + +# DigestCalcResponse +def calcResponse( + HA1, + algo, + pszNonce, + pszNonceCount, + pszCNonce, + pszQop, + pszMethod, + pszDigestUri, + pszHEntity, +): + m = algorithms[algo]() + m.update(pszMethod) + m.update(":") + m.update(pszDigestUri) + if pszQop == "auth-int": + m.update(":") + m.update(pszHEntity) + HA2 = m.digest().encode('hex') + + m = algorithms[algo]() + m.update(HA1) + m.update(":") + m.update(pszNonce) + m.update(":") + if pszNonceCount and pszCNonce and pszQop: + m.update(pszNonceCount) + m.update(":") + m.update(pszCNonce) + m.update(":") + m.update(pszQop) + m.update(":") + m.update(HA2) + respHash = m.digest().encode('hex') + return respHash + +class Unauthorized(Exception): + pass + +class AuthorizedHTTPGetter(client.HTTPPageGetter, LoggingMixIn): + + def handleStatus_401(self): + + self.quietLoss = 1 + self.transport.loseConnection() + + if not hasattr(self.factory, "username"): + self.factory.deferred.errback(failure.Failure(Unauthorized("Mail gateway not able to process reply; authentication required for calendar server"))) + return self.factory.deferred + + if hasattr(self.factory, "retried"): + self.factory.deferred.errback(failure.Failure(Unauthorized("Mail gateway not able to process reply; could not authenticate user %s with calendar server" % (self.factory.username,)))) + return self.factory.deferred + + self.factory.retried = True + + # self.log_debug("Got a 401 trying to inject [%s]" % (self.headers,)) + details = {} + basicAvailable = digestAvailable = False + wwwauth = self.headers.get("www-authenticate") + for item in wwwauth: + if item.startswith("basic "): + basicAvailable = True + if item.startswith("digest "): + digestAvailable = True + wwwauth = item[7:] + def unq(s): + if s[0] == s[-1] == '"': + return s[1:-1] + return s + parts = wwwauth.split(',') + for (k, v) in [p.split('=', 1) for p in parts]: + details[k.strip()] = unq(v.strip()) + + user = self.factory.username + pswd = self.factory.password + + if digestAvailable and details: + digest = calcResponse( + calcHA1( + details.get('algorithm'), + user, + details.get('realm'), + pswd, + details.get('nonce'), + details.get('cnonce') + ), + details.get('algorithm'), + details.get('nonce'), + details.get('nc'), + details.get('cnonce'), + details.get('qop'), + self.factory.method, + self.factory.url, + None + ) + + if details.get('qop'): + response = ( + 'Digest username="%s", realm="%s", nonce="%s", uri="%s", ' + 'response=%s, algorithm=%s, cnonce="%s", qop=%s, nc=%s' % + ( + user, + details.get('realm'), + details.get('nonce'), + self.factory.url, + digest, + details.get('algorithm'), + details.get('cnonce'), + details.get('qop'), + details.get('nc'), + ) + ) + else: + response = ( + 'Digest username="%s", realm="%s", nonce="%s", uri="%s", ' + 'response=%s, algorithm=%s' % + ( + user, + details.get('realm'), + details.get('nonce'), + self.factory.url, + digest, + details.get('algorithm'), + ) + ) + + self.factory.headers['Authorization'] = response + + if self.factory.scheme == 'https': + reactor.connectSSL(self.factory.host, self.factory.port, + self.factory, ssl.ClientContextFactory()) + else: + reactor.connectTCP(self.factory.host, self.factory.port, + self.factory) + # self.log_debug("Retrying with digest after 401") + + return self.factory.deferred + + elif basicAvailable: + basicauth = "%s:%s" % (user, pswd) + basicauth = "Basic " + base64.encodestring( basicauth ) + basicauth = basicauth.replace( "\n", "" ) + + self.factory.headers['Authorization'] = basicauth + + if self.factory.scheme == 'https': + reactor.connectSSL(self.factory.host, self.factory.port, + self.factory, ssl.ClientContextFactory()) + else: + reactor.connectTCP(self.factory.host, self.factory.port, + self.factory) + # self.log_debug("Retrying with basic after 401") + + return self.factory.deferred + + + else: + self.factory.deferred.errback(failure.Failure(Unauthorized("Mail gateway not able to process reply; calendar server returned 401 and doesn't support basic or digest"))) + return self.factory.deferred +