| 1 | # Copyright (C) 2007 AG Projects. |
|---|
| 2 | # |
|---|
| 3 | |
|---|
| 4 | """XCAP authentication module""" |
|---|
| 5 | |
|---|
| 6 | # XXX this module should be either renamed or refactored as it does more then just auth. |
|---|
| 7 | |
|---|
| 8 | from xcap import tweaks; tweaks.tweak_BasicCredentialFactory() |
|---|
| 9 | |
|---|
| 10 | from zope.interface import Interface, implements |
|---|
| 11 | |
|---|
| 12 | from twisted.internet import defer |
|---|
| 13 | from twisted.python import failure |
|---|
| 14 | from twisted.cred import credentials, portal, checkers, error as credError |
|---|
| 15 | from twisted.web2 import http, server, stream, responsecode |
|---|
| 16 | from twisted.web2.auth.wrapper import HTTPAuthResource, UnauthorizedResponse |
|---|
| 17 | |
|---|
| 18 | from application.configuration.datatypes import StringList |
|---|
| 19 | |
|---|
| 20 | from xcap import __version__ |
|---|
| 21 | from xcap.config import ConfigFile, ConfigSection |
|---|
| 22 | from xcap.appusage import getApplicationForURI, namespaces |
|---|
| 23 | from xcap.errors import ResourceNotFound |
|---|
| 24 | from xcap.uri import XCAPUser, XCAPUri |
|---|
| 25 | from xcap.root_uris import root_uris |
|---|
| 26 | |
|---|
| 27 | |
|---|
| 28 | # body of 404 error message to render when user requests xcap-root |
|---|
| 29 | # it's html, because XCAP root is often published on the web. |
|---|
| 30 | # NOTE: there're no plans to convert other error messages to html. |
|---|
| 31 | # Since a web-browser is not the primary tool for accessing XCAP server, text/plain |
|---|
| 32 | # is easier for clients to present to user/save to logs/etc. |
|---|
| 33 | WELCOME = ('<html><head><title>Not Found</title></head>' |
|---|
| 34 | '<body><h1>Not Found</h1>XCAP server does not serve anything ' |
|---|
| 35 | 'directly under XCAP Root URL. You have to be more specific.' |
|---|
| 36 | '<br><br>' |
|---|
| 37 | '<address><a href="http://www.openxcap.org">OpenXCAP/%s</address>' |
|---|
| 38 | '</body></html>') % __version__ |
|---|
| 39 | |
|---|
| 40 | |
|---|
| 41 | class AuthenticationConfig(ConfigSection): |
|---|
| 42 | _datatypes = {'trusted_peers': StringList, |
|---|
| 43 | 'default_realm': str} |
|---|
| 44 | default_realm = None |
|---|
| 45 | trusted_peers = [] |
|---|
| 46 | |
|---|
| 47 | configuration = ConfigFile() |
|---|
| 48 | configuration.read_settings('Authentication', AuthenticationConfig) |
|---|
| 49 | |
|---|
| 50 | def parseNodeURI(node_uri, default_realm): |
|---|
| 51 | """Parses the given Node URI, containing the XCAP root, document selector, |
|---|
| 52 | and node selector, and returns an XCAPUri instance if succesful.""" |
|---|
| 53 | xcap_root = None |
|---|
| 54 | for uri in root_uris: |
|---|
| 55 | if node_uri.startswith(uri): |
|---|
| 56 | xcap_root = uri |
|---|
| 57 | break |
|---|
| 58 | if xcap_root is None: |
|---|
| 59 | raise ResourceNotFound("XCAP root not found for URI: %s" % node_uri) |
|---|
| 60 | resource_selector = node_uri[len(xcap_root):] |
|---|
| 61 | if not resource_selector or resource_selector=='/': |
|---|
| 62 | raise ResourceNotFound(WELCOME) |
|---|
| 63 | r = XCAPUri(xcap_root, resource_selector, namespaces) |
|---|
| 64 | if r.user.domain is None: |
|---|
| 65 | r.user.domain = default_realm |
|---|
| 66 | return r |
|---|
| 67 | |
|---|
| 68 | |
|---|
| 69 | class ITrustedPeerCredentials(credentials.ICredentials): |
|---|
| 70 | |
|---|
| 71 | def checkPeer(self, trusted_peers): |
|---|
| 72 | pass |
|---|
| 73 | |
|---|
| 74 | |
|---|
| 75 | class TrustedPeerCredentials: |
|---|
| 76 | implements(ITrustedPeerCredentials) |
|---|
| 77 | |
|---|
| 78 | def __init__(self, peer): |
|---|
| 79 | self.peer = peer |
|---|
| 80 | |
|---|
| 81 | def checkPeer(self, trusted_peers): |
|---|
| 82 | return self.peer in trusted_peers |
|---|
| 83 | |
|---|
| 84 | ## credentials checkers |
|---|
| 85 | |
|---|
| 86 | class TrustedPeerChecker: |
|---|
| 87 | |
|---|
| 88 | implements(checkers.ICredentialsChecker) |
|---|
| 89 | credentialInterfaces = (ITrustedPeerCredentials,) |
|---|
| 90 | |
|---|
| 91 | def __init__(self, trusted_peers): |
|---|
| 92 | self.trusted_peers = trusted_peers |
|---|
| 93 | |
|---|
| 94 | def requestAvatarId(self, credentials): |
|---|
| 95 | """Return the avatar ID for the credentials which must have a 'peer' attribute, |
|---|
| 96 | or an UnauthorizedLogin in case of a failure.""" |
|---|
| 97 | if credentials.checkPeer(self.trusted_peers): |
|---|
| 98 | return defer.succeed(credentials.peer) |
|---|
| 99 | return defer.fail(credError.UnauthorizedLogin()) |
|---|
| 100 | |
|---|
| 101 | ## avatars |
|---|
| 102 | |
|---|
| 103 | class IAuthUser(Interface): |
|---|
| 104 | pass |
|---|
| 105 | |
|---|
| 106 | class ITrustedPeer(Interface): |
|---|
| 107 | pass |
|---|
| 108 | |
|---|
| 109 | class AuthUser(str): |
|---|
| 110 | """Authenticated XCAP User avatar.""" |
|---|
| 111 | implements(IAuthUser) |
|---|
| 112 | |
|---|
| 113 | class TrustedPeer(str): |
|---|
| 114 | """Trusted peer avatar.""" |
|---|
| 115 | implements(ITrustedPeer) |
|---|
| 116 | |
|---|
| 117 | ## realm |
|---|
| 118 | |
|---|
| 119 | class XCAPAuthRealm(object): |
|---|
| 120 | """XCAP authentication realm. Receives an avatar ID (a string identifying the user) |
|---|
| 121 | and a list of interfaces the avatar needs to support. It returns an avatar that |
|---|
| 122 | encapsulates data about that user.""" |
|---|
| 123 | implements(portal.IRealm) |
|---|
| 124 | |
|---|
| 125 | def requestAvatar(self, avatarId, mind, *interfaces): |
|---|
| 126 | if IAuthUser in interfaces: |
|---|
| 127 | return IAuthUser, AuthUser(avatarId) |
|---|
| 128 | elif ITrustedPeer in interfaces: |
|---|
| 129 | return ITrustedPeer, TrustedPeer(avatarId) |
|---|
| 130 | |
|---|
| 131 | raise NotImplementedError("Only IAuthUser and ITrustedPeer interfaces are supported") |
|---|
| 132 | |
|---|
| 133 | def get_cred(request, default_realm): |
|---|
| 134 | auth = request.headers.getHeader('authorization') |
|---|
| 135 | if auth: |
|---|
| 136 | typ, data = auth |
|---|
| 137 | if typ == 'basic': |
|---|
| 138 | return data.decode('base64').split(':', 1)[0], default_realm |
|---|
| 139 | elif typ == 'digest': |
|---|
| 140 | raise NotImplementedError |
|---|
| 141 | return None, default_realm |
|---|
| 142 | |
|---|
| 143 | ## authentication wrapper for XCAP resources |
|---|
| 144 | class XCAPAuthResource(HTTPAuthResource): |
|---|
| 145 | |
|---|
| 146 | def allowedMethods(self): |
|---|
| 147 | return ('GET', 'PUT', 'DELETE') |
|---|
| 148 | |
|---|
| 149 | def _updateRealm(self, realm): |
|---|
| 150 | """Updates the realm of the attached credential factories.""" |
|---|
| 151 | for factory in self.credentialFactories.values(): |
|---|
| 152 | factory.realm = realm |
|---|
| 153 | |
|---|
| 154 | def authenticate(self, request): |
|---|
| 155 | """Authenticates an XCAP request.""" |
|---|
| 156 | uri = request.scheme + "://" + request.host + request.uri |
|---|
| 157 | xcap_uri = parseNodeURI(uri, AuthenticationConfig.default_realm) |
|---|
| 158 | request.xcap_uri = xcap_uri |
|---|
| 159 | if xcap_uri.doc_selector.context=='global': |
|---|
| 160 | return defer.succeed(self.wrappedResource) |
|---|
| 161 | |
|---|
| 162 | ## For each request the authentication realm must be |
|---|
| 163 | ## dinamically deducted from the XCAP request URI |
|---|
| 164 | realm = xcap_uri.user.domain |
|---|
| 165 | |
|---|
| 166 | if not xcap_uri.user.username: |
|---|
| 167 | # for 'global' requests there's no username@domain in the URI, |
|---|
| 168 | # so we will use username and domain from Authorization header |
|---|
| 169 | xcap_uri.user.username, xcap_uri.user.domain = get_cred(request, AuthenticationConfig.default_realm) |
|---|
| 170 | |
|---|
| 171 | self._updateRealm(realm) |
|---|
| 172 | remote_addr = request.remoteAddr.host |
|---|
| 173 | if AuthenticationConfig.trusted_peers: |
|---|
| 174 | return self.portal.login(TrustedPeerCredentials(remote_addr), |
|---|
| 175 | None, |
|---|
| 176 | ITrustedPeer |
|---|
| 177 | ).addCallbacks(self._loginSucceeded, |
|---|
| 178 | self._trustedPeerLoginFailed, |
|---|
| 179 | (request,), None, |
|---|
| 180 | (request,), None) |
|---|
| 181 | return HTTPAuthResource.authenticate(self, request) |
|---|
| 182 | |
|---|
| 183 | def _trustedPeerLoginFailed(self, result, request): |
|---|
| 184 | """If the peer is not trusted, fallback to HTTP basic/digest authentication.""" |
|---|
| 185 | return HTTPAuthResource.authenticate(self, request) |
|---|
| 186 | |
|---|
| 187 | def _loginSucceeded(self, avatar, request): |
|---|
| 188 | """Authorizes an XCAP request after it has been authenticated.""" |
|---|
| 189 | |
|---|
| 190 | interface, avatar_id = avatar ## the avatar is the authenticated XCAP User |
|---|
| 191 | xcap_uri = request.xcap_uri |
|---|
| 192 | |
|---|
| 193 | application = getApplicationForURI(xcap_uri) |
|---|
| 194 | |
|---|
| 195 | if not application: |
|---|
| 196 | raise ResourceNotFound |
|---|
| 197 | |
|---|
| 198 | if interface is IAuthUser and application.is_authorized(XCAPUser.parse(avatar_id), xcap_uri): |
|---|
| 199 | return HTTPAuthResource._loginSucceeded(self, avatar, request) |
|---|
| 200 | elif interface is ITrustedPeer: |
|---|
| 201 | return HTTPAuthResource._loginSucceeded(self, avatar, request) |
|---|
| 202 | else: |
|---|
| 203 | return failure.Failure( |
|---|
| 204 | http.HTTPError( |
|---|
| 205 | UnauthorizedResponse( |
|---|
| 206 | self.credentialFactories, |
|---|
| 207 | request.remoteAddr))) |
|---|
| 208 | |
|---|
| 209 | def locateChild(self, request, seg): |
|---|
| 210 | """ |
|---|
| 211 | Authenticate the request then return the C{self.wrappedResource} |
|---|
| 212 | and the unmodified segments. |
|---|
| 213 | We're not using path location, we want to fall back to the renderHTTP() call. |
|---|
| 214 | """ |
|---|
| 215 | #return self.authenticate(request), seg |
|---|
| 216 | return self, server.StopTraversal |
|---|
| 217 | |
|---|
| 218 | def renderHTTP(self, request): |
|---|
| 219 | """ |
|---|
| 220 | Authenticate the request then return the result of calling renderHTTP |
|---|
| 221 | on C{self.wrappedResource} |
|---|
| 222 | """ |
|---|
| 223 | if request.method not in self.allowedMethods(): |
|---|
| 224 | response = http.Response(responsecode.NOT_ALLOWED) |
|---|
| 225 | response.headers.setHeader("allow", self.allowedMethods()) |
|---|
| 226 | return response |
|---|
| 227 | |
|---|
| 228 | def _renderResource(resource): |
|---|
| 229 | return resource.renderHTTP(request) |
|---|
| 230 | |
|---|
| 231 | def _finished_reading(ignore, result): |
|---|
| 232 | data = ''.join(result) |
|---|
| 233 | request.attachment = data |
|---|
| 234 | d = self.authenticate(request) |
|---|
| 235 | d.addCallback(_renderResource) |
|---|
| 236 | return d |
|---|
| 237 | |
|---|
| 238 | if request.method in ('PUT', 'DELETE'): |
|---|
| 239 | # we need to authenticate the request after all the attachment stream |
|---|
| 240 | # has been read |
|---|
| 241 | # QQQ DELETE doesn't have any attachments, does it? nor does GET. |
|---|
| 242 | # QQQ Reading attachment when there isn't one won't hurt, will it? |
|---|
| 243 | # QQQ So why don't we just do it all the time for all requests? |
|---|
| 244 | data = [] |
|---|
| 245 | d = stream.readStream(request.stream, data.append) |
|---|
| 246 | d.addCallback(_finished_reading, data) |
|---|
| 247 | return d |
|---|
| 248 | else: |
|---|
| 249 | d = self.authenticate(request) |
|---|
| 250 | d.addCallback(_renderResource) |
|---|
| 251 | |
|---|
| 252 | return d |
|---|