diff --git a/xcap/authentication.py b/xcap/authentication.py index 24bb566..413f793 100644 --- a/xcap/authentication.py +++ b/xcap/authentication.py @@ -1,336 +1,366 @@ """XCAP authentication module""" # XXX this module should be either renamed or refactored as it does more then just auth. - -from xcap import tweaks; tweaks.tweak_BasicCredentialFactory() - +from hashlib import md5 from zope.interface import Interface, implements from twisted.internet import defer from twisted.python import failure from twisted.cred import credentials, portal, checkers, error as credError from twisted.web2 import http, server, stream, responsecode, http_headers from twisted.web2.auth.wrapper import HTTPAuthResource, UnauthorizedResponse from application.configuration.datatypes import NetworkRangeList from application.configuration import ConfigSection, ConfigSetting import struct import socket import urlparse import xcap from xcap.datatypes import XCAPRootURI from xcap.appusage import getApplicationForURI, namespaces, public_get_applications from xcap.errors import ResourceNotFound from xcap.uri import XCAPUser, XCAPUri +from twisted.web2.auth import basic, digest # body of 404 error message to render when user requests xcap-root # it's html, because XCAP root is often published on the web. # NOTE: there're no plans to convert other error messages to html. # Since a web-browser is not the primary tool for accessing XCAP server, text/plain # is easier for clients to present to user/save to logs/etc. WELCOME = ('Not Found' '

Not Found

XCAP server does not serve anything ' 'directly under XCAP Root URL. You have to be more specific.' '

' '
OpenXCAP/%s
' '') % xcap.__version__ class AuthenticationConfig(ConfigSection): __cfgfile__ = xcap.__cfgfile__ __section__ = 'Authentication' default_realm = ConfigSetting(type=str, value=None) trusted_peers = ConfigSetting(type=NetworkRangeList, value=NetworkRangeList('none')) class ServerConfig(ConfigSection): __cfgfile__ = xcap.__cfgfile__ __section__ = 'Server' root = ConfigSetting(type=XCAPRootURI, value=None) def generateWWWAuthenticate(headers): _generated = [] for seq in headers: scheme, challenge = seq[0], seq[1] # If we're going to parse out to something other than a dict # we need to be able to generate from something other than a dict try: l = [] for k,v in dict(challenge).iteritems(): l.append("%s=%s" % (k, k in ("algorithm", "stale") and v or http_headers.quoteString(v))) _generated.append("%s %s" % (scheme, ", ".join(l))) except ValueError: _generated.append("%s %s" % (scheme, challenge)) return _generated http_headers.generator_response_headers["WWW-Authenticate"] = (generateWWWAuthenticate,) http_headers.DefaultHTTPHandler.updateGenerators(http_headers.generator_response_headers) del generateWWWAuthenticate def parseNodeURI(node_uri, default_realm): """Parses the given Node URI, containing the XCAP root, document selector, and node selector, and returns an XCAPUri instance if succesful.""" xcap_root = None for uri in ServerConfig.root.uris: if node_uri.startswith(uri): xcap_root = uri break if xcap_root is None: raise ResourceNotFound("XCAP root not found for URI: %s" % node_uri) resource_selector = node_uri[len(xcap_root):] if not resource_selector or resource_selector=='/': raise ResourceNotFound(WELCOME, http_headers.MimeType("text", "html")) r = XCAPUri(xcap_root, resource_selector, namespaces) if r.user.domain is None: r.user.domain = default_realm return r class ITrustedPeerCredentials(credentials.ICredentials): def checkPeer(self, trusted_peers): pass class TrustedPeerCredentials(object): implements(ITrustedPeerCredentials) def __init__(self, peer): self.peer = peer def checkPeer(self, trusted_peers): for range in trusted_peers: if struct.unpack('!L', socket.inet_aton(self.peer))[0] & range[1] == range[0]: return True return False class IPublicGetApplicationCredentials(credentials.ICredentials): def checkApplication(self): pass class PublicGetApplicationCredentials(object): implements(IPublicGetApplicationCredentials) def checkApplication(self): return True ## credentials checkers class TrustedPeerChecker(object): implements(checkers.ICredentialsChecker) credentialInterfaces = (ITrustedPeerCredentials,) def __init__(self, trusted_peers): self.trusted_peers = trusted_peers def requestAvatarId(self, credentials): """Return the avatar ID for the credentials which must have a 'peer' attribute, or an UnauthorizedLogin in case of a failure.""" if credentials.checkPeer(self.trusted_peers): return defer.succeed(credentials.peer) return defer.fail(credError.UnauthorizedLogin()) class PublicGetApplicationChecker(object): implements(checkers.ICredentialsChecker) credentialInterfaces = (IPublicGetApplicationCredentials,) def requestAvatarId(self, credentials): """We already know that the method is GET and the application is a 'public GET application', we just need to say that the authentication succeeded.""" if credentials.checkApplication(): return defer.succeed(None) return defer.fail(credError.UnauthorizedLogin()) ## avatars class IAuthUser(Interface): pass class ITrustedPeer(Interface): pass class IPublicGetApplication(Interface): pass class AuthUser(str): """Authenticated XCAP User avatar.""" implements(IAuthUser) class TrustedPeer(str): """Trusted peer avatar.""" implements(ITrustedPeer) class PublicGetApplication(str): """Public get application avatar.""" implements(IPublicGetApplication) ## realm class XCAPAuthRealm(object): """XCAP authentication realm. Receives an avatar ID (a string identifying the user) and a list of interfaces the avatar needs to support. It returns an avatar that encapsulates data about that user.""" implements(portal.IRealm) def requestAvatar(self, avatarId, mind, *interfaces): if IAuthUser in interfaces: return IAuthUser, AuthUser(avatarId) elif ITrustedPeer in interfaces: return ITrustedPeer, TrustedPeer(avatarId) elif IPublicGetApplication in interfaces: return IPublicGetApplication, PublicGetApplication(avatarId) raise NotImplementedError("Only IAuthUser and ITrustedPeer interfaces are supported") def get_cred(request, default_realm): auth = request.headers.getHeader('authorization') if auth: typ, data = auth if typ == 'basic': return data.decode('base64').split(':', 1)[0], default_realm elif typ == 'digest': raise NotImplementedError return None, default_realm ## authentication wrapper for XCAP resources class XCAPAuthResource(HTTPAuthResource): def allowedMethods(self): return 'GET', 'PUT', 'DELETE' def _updateRealm(self, realm): """Updates the realm of the attached credential factories.""" for factory in self.credentialFactories.values(): factory.realm = realm def authenticate(self, request): """Authenticates an XCAP request.""" parsed_url = urlparse.urlparse(request.uri) if request.port in (80, 443): uri = request.scheme + "://" + request.host + parsed_url.path else: uri = request.scheme + "://" + request.host + ":" + str(request.port) + parsed_url.path if parsed_url.query: uri += "?%s" % parsed_url.query xcap_uri = parseNodeURI(uri, AuthenticationConfig.default_realm) request.xcap_uri = xcap_uri if xcap_uri.doc_selector.context=='global': return defer.succeed(self.wrappedResource) ## For each request the authentication realm must be ## dinamically deducted from the XCAP request URI realm = xcap_uri.user.domain if realm is None: raise ResourceNotFound('Unknown domain (the domain part of "username@domain" is required because this server has no default domain)') if not xcap_uri.user.username: # for 'global' requests there's no username@domain in the URI, # so we will use username and domain from Authorization header xcap_uri.user.username, xcap_uri.user.domain = get_cred(request, AuthenticationConfig.default_realm) self._updateRealm(realm) # If we receive a GET to a 'public GET application' we will not authenticate it if request.method == "GET" and public_get_applications.has_key(xcap_uri.application_id): return self.portal.login(PublicGetApplicationCredentials(), None, IPublicGetApplication ).addCallbacks(self._loginSucceeded, self._publicGetApplicationLoginFailed, (request,), None, (request,), None) remote_addr = request.remoteAddr.host if AuthenticationConfig.trusted_peers: return self.portal.login(TrustedPeerCredentials(remote_addr), None, ITrustedPeer ).addCallbacks(self._loginSucceeded, self._trustedPeerLoginFailed, (request,), None, (request,), None) return HTTPAuthResource.authenticate(self, request) def _trustedPeerLoginFailed(self, result, request): """If the peer is not trusted, fallback to HTTP basic/digest authentication.""" return HTTPAuthResource.authenticate(self, request) def _publicGetApplicationLoginFailed(self, result, request): return HTTPAuthResource.authenticate(self, request) def _loginSucceeded(self, avatar, request): """Authorizes an XCAP request after it has been authenticated.""" interface, avatar_id = avatar ## the avatar is the authenticated XCAP User xcap_uri = request.xcap_uri application = getApplicationForURI(xcap_uri) if not application: raise ResourceNotFound if interface is IAuthUser and application.is_authorized(XCAPUser.parse(avatar_id), xcap_uri): return HTTPAuthResource._loginSucceeded(self, avatar, request) elif interface is ITrustedPeer or interface is IPublicGetApplication: return HTTPAuthResource._loginSucceeded(self, avatar, request) else: return failure.Failure( http.HTTPError( UnauthorizedResponse( self.credentialFactories, request.remoteAddr))) def locateChild(self, request, seg): """ Authenticate the request then return the C{self.wrappedResource} and the unmodified segments. We're not using path location, we want to fall back to the renderHTTP() call. """ #return self.authenticate(request), seg return self, server.StopTraversal def renderHTTP(self, request): """ Authenticate the request then return the result of calling renderHTTP on C{self.wrappedResource} """ if request.method not in self.allowedMethods(): response = http.Response(responsecode.NOT_ALLOWED) response.headers.setHeader("allow", self.allowedMethods()) return response def _renderResource(resource): return resource.renderHTTP(request) def _finished_reading(ignore, result): data = ''.join(result) request.attachment = data d = self.authenticate(request) d.addCallback(_renderResource) return d if request.method in ('PUT', 'DELETE'): # we need to authenticate the request after all the attachment stream # has been read # QQQ DELETE doesn't have any attachments, does it? nor does GET. # QQQ Reading attachment when there isn't one won't hurt, will it? # QQQ So why don't we just do it all the time for all requests? data = [] d = stream.readStream(request.stream, data.append) d.addCallback(_finished_reading, data) return d else: d = self.authenticate(request) d.addCallback(_renderResource) return d + + +class BasicCredentials(credentials.UsernamePassword): + """Custom Basic Credentials, which support both plain and hashed checks.""" + + implements(credentials.IUsernamePassword, digest.IUsernameDigestHash) + + def __init__(self, username, password, realm): + credentials.UsernamePassword.__init__(self, username, password) + self.realm = realm + + @property + def hash(self): + return md5('{0.username}:{0.realm}:{0.password}'.format(self)).hexdigest() + + def checkHash(self, digestHash): + return digestHash == self.hash + + +class BasicCredentialFactory(basic.BasicCredentialFactory): + def decode(self, response, request): + credential = super(BasicCredentialFactory, self).decode(response, request) + return BasicCredentials(credential.username, credential.password, self.realm) + + +class DigestCredentialFactory(digest.DigestCredentialFactory): + def generateOpaque(self, nonce, clientip): + return super(DigestCredentialFactory, self).generateOpaque(nonce=nonce, clientip=clientip or '') + + def verifyOpaque(self, opaque, nonce, clientip): + return super(DigestCredentialFactory, self).verifyOpaque(opaque=opaque, nonce=nonce, clientip=clientip or '') diff --git a/xcap/server.py b/xcap/server.py index c62954d..2259d7f 100644 --- a/xcap/server.py +++ b/xcap/server.py @@ -1,179 +1,177 @@ """HTTP handling for the XCAP server""" from __future__ import absolute_import import resource as _resource import sys from application.configuration.datatypes import IPAddress, NetworkRangeList from application.configuration import ConfigSection, ConfigSetting from application import log from twisted.internet import reactor from twisted.web2 import channel, resource, http, responsecode, server from twisted.cred.portal import Portal -from twisted.web2.auth import basic -from xcap.tweaks import tweak_DigestCredentialFactory import xcap from xcap import authentication from xcap.datatypes import XCAPRootURI from xcap.appusage import getApplicationForURI, Backend from xcap.resource import XCAPDocument, XCAPElement, XCAPAttribute, XCAPNamespaceBinding from xcap.logutil import web_logger from xcap.tls import Certificate, PrivateKey from xcap.xpath import AttributeSelector, NamespaceSelector server.VERSION = "OpenXCAP/%s" % xcap.__version__ class AuthenticationConfig(ConfigSection): __cfgfile__ = xcap.__cfgfile__ __section__ = 'Authentication' type = 'digest' cleartext_passwords = True default_realm = ConfigSetting(type=str, value=None) trusted_peers = ConfigSetting(type=NetworkRangeList, value=NetworkRangeList('none')) class ServerConfig(ConfigSection): __cfgfile__ = xcap.__cfgfile__ __section__ = 'Server' address = ConfigSetting(type=IPAddress, value='0.0.0.0') root = ConfigSetting(type=XCAPRootURI, value=None) backend = ConfigSetting(type=Backend, value=None) class TLSConfig(ConfigSection): __cfgfile__ = xcap.__cfgfile__ __section__ = 'TLS' certificate = ConfigSetting(type=Certificate, value=None) private_key = ConfigSetting(type=PrivateKey, value=None) if ServerConfig.root is None: log.critical('The XCAP root URI is not defined') sys.exit(1) if ServerConfig.backend is None: log.critical('OpenXCAP needs a backend to be specified in order to run') sys.exit(1) # Increase the system limit for the maximum number of open file descriptors try: _resource.setrlimit(_resource.RLIMIT_NOFILE, (99999, 99999)) except ValueError: log.warning('Could not raise open file descriptor limit') class XCAPRoot(resource.Resource, resource.LeafResource): addSlash = True def allowedMethods(self): # not used , but methods were already checked by XCAPAuthResource return ('GET', 'PUT', 'DELETE') def resourceForURI(self, xcap_uri): application = getApplicationForURI(xcap_uri) if not xcap_uri.node_selector: return XCAPDocument(xcap_uri, application) else: terminal_selector = xcap_uri.node_selector.terminal_selector if isinstance(terminal_selector, AttributeSelector): return XCAPAttribute(xcap_uri, application) elif isinstance(terminal_selector, NamespaceSelector): return XCAPNamespaceBinding(xcap_uri, application) else: return XCAPElement(xcap_uri, application) def renderHTTP(self, request): application = getApplicationForURI(request.xcap_uri) if not application: return http.Response(responsecode.NOT_FOUND, stream="Application not supported") resource = self.resourceForURI(request.xcap_uri) return resource.renderHTTP(request) class Request(server.Request): def writeResponse(self, response): web_logger.log_access(request=self, response=response) return server.Request.writeResponse(self, response) class HTTPChannel(channel.http.HTTPChannel): inputTimeOut = 30 def __init__(self): channel.http.HTTPChannel.__init__(self) # if connection wasn't completed for 30 seconds, terminate it, # this avoids having lingering TCP connections which don't complete # the TLS handshake self.setTimeout(30) def timeoutConnection(self): if self.transport: log.info('Timing out client: {}'.format(self.transport.getPeer())) channel.http.HTTPChannel.timeoutConnection(self) class HTTPFactory(channel.HTTPFactory): noisy = False protocol = HTTPChannel class XCAPSite(server.Site): def __call__(self, *args, **kwargs): return Request(site=self, *args, **kwargs) class XCAPServer(object): def __init__(self): portal = Portal(authentication.XCAPAuthRealm()) if AuthenticationConfig.cleartext_passwords: http_checker = ServerConfig.backend.PlainPasswordChecker() else: http_checker = ServerConfig.backend.HashPasswordChecker() portal.registerChecker(http_checker) trusted_peers = AuthenticationConfig.trusted_peers portal.registerChecker(authentication.TrustedPeerChecker(trusted_peers)) portal.registerChecker(authentication.PublicGetApplicationChecker()) auth_type = AuthenticationConfig.type if auth_type == 'basic': - credential_factory = basic.BasicCredentialFactory(auth_type) + credential_factory = authentication.BasicCredentialFactory(auth_type) elif auth_type == 'digest': - credential_factory = tweak_DigestCredentialFactory('MD5', auth_type) + credential_factory = authentication.DigestCredentialFactory('MD5', auth_type) else: raise ValueError('Invalid authentication type: %r. Please check the configuration.' % auth_type) root = authentication.XCAPAuthResource(XCAPRoot(), (credential_factory,), portal, (authentication.IAuthUser,)) self.site = XCAPSite(root) def _start_https(self, reactor): from gnutls.interfaces.twisted import X509Credentials from gnutls.connection import TLSContext, TLSContextServerOptions cert, pKey = TLSConfig.certificate, TLSConfig.private_key if cert is None or pKey is None: log.critical('The TLS certificate/key could not be loaded') sys.exit(1) credentials = X509Credentials(cert, pKey) tls_context = TLSContext(credentials, server_options=TLSContextServerOptions(certificate_request=None)) reactor.listenTLS(ServerConfig.root.port, HTTPFactory(self.site), tls_context, interface=ServerConfig.address) log.info('TLS started') def start(self): log.info('Listening on: %s:%d' % (ServerConfig.address, ServerConfig.root.port)) log.info('XCAP root: %s' % ServerConfig.root) if ServerConfig.root.startswith('https'): self._start_https(reactor) else: reactor.listenTCP(ServerConfig.root.port, HTTPFactory(self.site), interface=ServerConfig.address) reactor.run(installSignalHandlers=ServerConfig.backend.installSignalHandlers) diff --git a/xcap/tweaks.py b/xcap/tweaks.py deleted file mode 100644 index 24b3c07..0000000 --- a/xcap/tweaks.py +++ /dev/null @@ -1,61 +0,0 @@ - -from hashlib import md5 -from twisted.cred import credentials, error -from twisted.web2.auth.digest import IUsernameDigestHash, DigestCredentialFactory - -from zope.interface import implements - -def makeHash(username, realm, password): - s = '%s:%s:%s' % (username, realm, password) - return md5(s).hexdigest() - -class BasicCredentials(credentials.UsernamePassword): - """Custom Basic Credentials, which support both plain and hashed checks.""" - - implements(credentials.IUsernamePassword, IUsernameDigestHash) - - def __init__(self, username, password, realm): - self.username = username - self.password = password - self.realm = realm - - def checkHash(self, digestHash): - return digestHash == makeHash(self.username, self.realm, self.password) - - -def decode(self, response, request): - try: - creds = (response + '===').decode('base64') - except Exception: - raise error.LoginFailed('Invalid credentials') - - creds = creds.split(':', 1) - if len(creds) == 2: - creds = BasicCredentials(creds[0], creds[1], self.realm) # our change - return creds - else: - raise error.LoginFailed('Invalid credentials') - -def tweak_BasicCredentialFactory(): - import new - from twisted.web2.auth.basic import BasicCredentialFactory - method = new.instancemethod(decode, None, BasicCredentialFactory) - BasicCredentialFactory.decode = method - -class tweak_DigestCredentialFactory(DigestCredentialFactory): - - def generateOpaque(self, nonce, clientip): - """ - Generate an opaque to be returned to the client. This is a unique - string that can be returned to us and verified. - """ - # Now, what we do is encode the nonce, client ip and a timestamp in the - # opaque value with a suitable digest. - now = str(int(self._getTime())) - if clientip is None: - clientip = '' - key = "%s,%s,%s" % (nonce, clientip, now) - digest = md5(key + self.privateKey).hexdigest() - ekey = key.encode('base64') - return "%s-%s" % (digest, ekey.replace('\n', '')) -