txhttputil 1.4.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,224 @@
1
+ # Copyright (c) 2016-2026 Flying Ion Pty Ltd
2
+ #
3
+ # This software is open source; the MIT license applies.
4
+ # See LICENSE for the full license text.
5
+ #
6
+ # Website : https://flyingion.com
7
+ # Support : contact@flyingion.com
8
+
9
+ import logging
10
+ from datetime import datetime
11
+
12
+ import pytz
13
+ from twisted.web.resource import IResource, ErrorPage
14
+ from twisted.web.util import DeferredResource
15
+ from txhttputil.site.AuthCredentials import AuthCredentials
16
+ from txhttputil.site.AuthUserDetails import IUserSession
17
+ from txhttputil.site.BasicResource import BasicResource
18
+ from zope.interface import implementer
19
+
20
+ from .AuthResource import LoginResource, LoginSucceededResource
21
+
22
+ logger = logging.getLogger(name=__name__)
23
+
24
+ __author__ = "Flying Ion Pty Ltd"
25
+
26
+
27
+ @implementer(IResource)
28
+ class FormBasedAuthSessionWrapper(object):
29
+ """
30
+ Copied partly from C{twisted.web._auth.wrapper.HTTPAuthSessionWrapper}
31
+ """
32
+
33
+ isLeaf = False
34
+
35
+ def __init__(
36
+ self,
37
+ siteRootResource: BasicResource,
38
+ credentialChecker: AuthCredentials,
39
+ allowedIpsList=None,
40
+ ):
41
+ self._siteRootResource = siteRootResource
42
+ self._credentialChecker = credentialChecker
43
+ self._allowedIpsList = allowedIpsList
44
+
45
+ def _authorizedResource(self, request):
46
+ """
47
+ Get the L{IResource} which the given request is authorized to receive.
48
+ If the proper authorization headers are present, the resource will be
49
+ requested from the portal. If not, an anonymous login_page attempt will be
50
+ made.
51
+ """
52
+
53
+ # If the user has a logged in session, let them continue
54
+ # With out this, every HTTP request would result in a login screen.
55
+ userSession = IUserSession(request.getSession())
56
+ if userSession.userDetails.loggedIn:
57
+ request.getSession().touch()
58
+ return self._siteRootResource
59
+
60
+ # TRY Basic HTTP Authentication
61
+ # TODO, txHttpUtil is using FORM based authentication instead
62
+ # authheader = request.getHeader('authorization')
63
+ # if authheader:
64
+ # factory, respString = self._selectParseHeader(authheader)
65
+ # if factory is None:
66
+ # return LoginResource()
67
+ #
68
+ # try:
69
+ # credentials = factory.decode(respString, request)
70
+ # except error.LoginFailed:
71
+ # return LoginResource()
72
+ # except:
73
+ # logger.error("Unexpected failure from credentials factory")
74
+ # return ErrorPage(500, None, None)
75
+ # else:
76
+ # return DeferredResource(self._login(credentials))
77
+
78
+ # TRY form based authentication
79
+
80
+ a = request.args
81
+ if b"username" in a and b"password" in a:
82
+ user = a[b"username"][0]
83
+ pass_ = a[b"password"][0]
84
+
85
+ deferred = self._credentialChecker.check(user, pass_)
86
+
87
+ def checkResult(userDetails):
88
+ if not userDetails:
89
+ return LoginResource()
90
+
91
+ userDetails.loggedIn = True
92
+ userDetails.loginDate = datetime.now(pytz.utc)
93
+
94
+ userSession = IUserSession(request.getSession())
95
+ userSession.userDetails = userDetails
96
+
97
+ return LoginSucceededResource()
98
+
99
+ def error(failure):
100
+ logger.error("Unexpected failure from credentials factory")
101
+ logger.error(failure.value)
102
+ return ErrorPage(500, None, None)
103
+
104
+ deferred.addCallback(checkResult)
105
+ deferred.addErrback(error)
106
+
107
+ return DeferredResource(deferred)
108
+
109
+ # Return login_page form
110
+ return LoginResource()
111
+
112
+ def render(self, request):
113
+ """
114
+ Find the L{IResource} avatar suitable for the given request, if
115
+ possible, and render it. Otherwise, perhaps render an error page
116
+ requiring authorization or describing an internal server failure.
117
+ """
118
+ # Check IP filtering if configured
119
+ if self._allowedIpsList is not None:
120
+ clientIp = request.getClientAddress().host
121
+ if clientIp not in self._allowedIpsList:
122
+ request.setResponseCode(403)
123
+ return b"Forbidden: IP address not allowed"
124
+
125
+ return self._authorizedResource(request).render(request)
126
+
127
+ def getChildWithDefault(self, path, request):
128
+ """
129
+ Inspect the Authorization HTTP header, and return a deferred which,
130
+ when fired after successful authentication, will return an authorized
131
+ C{Avatar}. On authentication failure, an C{UnauthorizedResource} will
132
+ be returned, essentially halting further dispatch on the wrapped
133
+ resource and all children
134
+ """
135
+ # Check IP filtering if configured
136
+ if self._allowedIpsList is not None:
137
+ clientIp = request.getClientAddress().host
138
+ if clientIp not in self._allowedIpsList:
139
+ from twisted.web.resource import ErrorPage
140
+
141
+ return ErrorPage(403, b"Forbidden", b"IP address not allowed")
142
+
143
+ # Don't consume any segments of the request - this class should be
144
+ # transparent!
145
+ request.postpath.insert(0, request.prepath.pop())
146
+ return self._authorizedResource(request)
147
+
148
+ # def _login(self, credentials):
149
+ # """
150
+ # Get the L{IResource} avatar for the given credentials.
151
+ #
152
+ # @return: A L{Deferred} which will be called back with an L{IResource}
153
+ # avatar or which will errback if authentication fails.
154
+ # """
155
+ # d = self._portal.login(credentials, None, IResource)
156
+ # d.addCallbacks(self._loginSucceeded, self._loginFailed)
157
+ # return d
158
+ #
159
+ # def _loginSucceeded(self, (interface, avatar, logout)):
160
+ # """
161
+ # Handle login_page success by wrapping the resulting L{IResource} avatar
162
+ # so that the C{logout} callback will be invoked when rendering is
163
+ # complete.
164
+ # """
165
+ #
166
+ # class ResourceWrapper(proxyForInterface(IResource, 'resource')):
167
+ # """
168
+ # Wrap an L{IResource} so that whenever it or a child of it
169
+ # completes rendering, the cred logout hook will be invoked.
170
+ #
171
+ # An assumption is made here that exactly one L{IResource} from
172
+ # among C{avatar} and all of its children will be rendered. If
173
+ # more than one is rendered, C{logout} will be invoked multiple
174
+ # times and probably earlier than desired.
175
+ # """
176
+ #
177
+ # def getChildWithDefault(self, name, request):
178
+ # """
179
+ # Pass through the lookup to the wrapped resource, wrapping
180
+ # the result in L{ResourceWrapper} to ensure C{logout} is
181
+ # called when rendering of the child is complete.
182
+ # """
183
+ # return ResourceWrapper(self.resource.getChildWithDefault(name, request))
184
+ #
185
+ # def render(self, request):
186
+ # """
187
+ # Hook into response generation so that when rendering has
188
+ # finished completely (with or without error), C{logout} is
189
+ # called.
190
+ # """
191
+ # request.notifyFinish().addBoth(lambda ign: logout())
192
+ # return super(ResourceWrapper, self).render(request)
193
+ #
194
+ # return ResourceWrapper(avatar)
195
+ #
196
+ # def _loginFailed(self, result):
197
+ # """
198
+ # Handle login_page failure by presenting either another challenge (for
199
+ # expected authentication/authorization-related failures) or a server
200
+ # error page (for anything else).
201
+ # """
202
+ # if result.check(error.Unauthorized, error.LoginFailed):
203
+ # return UnauthorizedResource(self._credentialFactories)
204
+ # else:
205
+ # logger.error(str(result) +
206
+ # "HTTPAuthSessionWrapper.getChildWithDefault encountered "
207
+ # "unexpected error")
208
+ # return ErrorPage(500, None, None)
209
+ #
210
+ # def _selectParseHeader(self, header):
211
+ # """
212
+ # Choose an C{ICredentialFactory} from C{_credentialFactories}
213
+ # suitable to use to decode the given I{Authenticate} header.
214
+ #
215
+ # @return: A two-tuple of a factory and the remaining portion of the
216
+ # header value to be decoded or a two-tuple of C{None} if no
217
+ # factory can decode the header value.
218
+ # """
219
+ # elements = header.split(' ')
220
+ # scheme = elements[0].lower()
221
+ # for fact in self._credentialFactories:
222
+ # if fact.scheme == scheme:
223
+ # return (fact, ' '.join(elements[1:]))
224
+ # return (None, None)
@@ -0,0 +1,51 @@
1
+ # Copyright (c) 2016-2026 Flying Ion Pty Ltd
2
+ #
3
+ # This software is open source; the MIT license applies.
4
+ # See LICENSE for the full license text.
5
+ #
6
+ # Website : https://flyingion.com
7
+ # Support : contact@flyingion.com
8
+
9
+ from twisted.python.components import registerAdapter
10
+ from twisted.web.server import Session
11
+ from zope.interface import Attribute
12
+ from zope.interface import Interface
13
+ from zope.interface import implementer
14
+
15
+
16
+ # TODO, Expand on this to allow restrictions to files, etc
17
+ class GroupDetails:
18
+ def __init__(self, name):
19
+ self.name = name
20
+ self.readOnly = False
21
+
22
+
23
+ everyoneGroup = GroupDetails("everyone")
24
+
25
+
26
+ # TODO, Expand on this to allow username, etc
27
+ class UserDetails:
28
+ def __init__(self):
29
+ self.loggedIn = False
30
+ self.loginDate = None
31
+ self.groupDetails = None
32
+
33
+
34
+ class IUserSession(Interface):
35
+ userDetails = Attribute("The details of the user.")
36
+
37
+
38
+ @implementer(IUserSession)
39
+ class UserSession(object):
40
+ ''' User Access
41
+ This class stores the details about the user that are required by the
42
+ elements and resources to be able to allow access.
43
+ '''
44
+
45
+ def __init__(self, session):
46
+ self.userDetails = UserDetails()
47
+ self.readOnly = True
48
+ self.groupId = None
49
+
50
+
51
+ registerAdapter(UserSession, Session, IUserSession)
@@ -0,0 +1,155 @@
1
+ # Copyright (c) 2016-2026 Flying Ion Pty Ltd
2
+ #
3
+ # This software is open source; the MIT license applies.
4
+ # See LICENSE for the full license text.
5
+ #
6
+ # Website : https://flyingion.com
7
+ # Support : contact@flyingion.com
8
+
9
+ import logging
10
+
11
+ from twisted.python.compat import nativeString
12
+ from twisted.web.error import UnsupportedMethod
13
+ from twisted.web.resource import EncodingResourceWrapper, IResource, NoResource, \
14
+ _computeAllowedMethods
15
+ from twisted.web.server import GzipEncoderFactory
16
+ from zope.interface import implementer
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ import mimetypes
21
+
22
+ mimetypes.init()
23
+
24
+
25
+ def get_extensions_for_type(general_type):
26
+ for ext in mimetypes.types_map:
27
+ if mimetypes.types_map[ext].split('/')[0] == general_type:
28
+ yield ext
29
+
30
+
31
+ IMAGE_EXTENSIONS = list(get_extensions_for_type('image'))
32
+ FONT_EXTENSIONS = list(get_extensions_for_type('font'))
33
+
34
+
35
+ @implementer(IResource)
36
+ class BasicResource:
37
+ """ Basic Resource
38
+
39
+ This class is a node for the resource tree, It's a slightly simpler version of
40
+ C{twisted.web.resource.Resource}
41
+
42
+ """
43
+ isGzipped = False
44
+ entityType = IResource
45
+ server = None
46
+ isLeaf = False
47
+
48
+ def __init__(self, allowedIpsList=None):
49
+ """
50
+ Initialize.
51
+ """
52
+ self._children = {}
53
+ self._allowedIpsList = allowedIpsList
54
+
55
+ def getChildWithDefault(self, path, request):
56
+ """ Get Child With Default
57
+
58
+ This is the method queried by the site/server, if we implement this and then
59
+ only use getChild, we have greater control when something fails
60
+ @see C{FileUnderlayResource}
61
+
62
+
63
+ def getChildForRequest(resource, request):
64
+ # Traverse resource tree to find who will handle the request.
65
+ while request.postpath and not resource.isLeaf:
66
+ pathElement = request.postpath.pop(0)
67
+ request.prepath.append(pathElement)
68
+ resource = resource.getChildWithDefault(pathElement, request)
69
+ return resource
70
+
71
+ """
72
+
73
+ resource = self
74
+
75
+ from txhttputil.site.FileUnderlayResource import FileUnderlayResource
76
+ fileUnderlayResourceStack = []
77
+
78
+ if isinstance(self, FileUnderlayResource):
79
+ fileUnderlayResourceStack.append((resource, [path] + request.postpath))
80
+
81
+ while True:
82
+ resource = resource.getChild(path, request)
83
+
84
+ if isinstance(resource, FileUnderlayResource):
85
+ fileUnderlayResourceStack.append((resource, list(request.postpath)))
86
+
87
+ # If we've run into a dead end, return it.
88
+ if isinstance(resource, NoResource):
89
+ break
90
+
91
+ # If the resource is a leaf, this IS the resource we should render
92
+ if resource.isLeaf:
93
+ # Break before popping the path
94
+ break
95
+
96
+ # If there are no more paths to pop, this must be it
97
+ if not request.postpath:
98
+ break
99
+
100
+ path = request.postpath.pop(0)
101
+ request.prepath.append(path)
102
+
103
+ # Look back through the file resources and see if there are any matches
104
+ if isinstance(resource, NoResource) or isinstance(resource, FileUnderlayResource):
105
+ while fileUnderlayResourceStack:
106
+ resource, postPath = fileUnderlayResourceStack.pop()
107
+ fileResource = resource.getFileResource(postPath)
108
+ if not isinstance(fileResource, NoResource):
109
+ return fileResource
110
+
111
+ return resource
112
+
113
+ def getChild(self, path, request):
114
+ if path in self._children:
115
+ return self._children[path]
116
+ return NoResource()
117
+
118
+ def putChild(self, path: bytes, child):
119
+ if b'/' in path:
120
+ raise Exception("Path %s can not start or end with '/' ", path)
121
+
122
+ self._children[path] = child
123
+ child.server = self.server
124
+
125
+ def deleteChild(self, path: bytes):
126
+ if b'/' in path:
127
+ raise Exception("Path %s can not start or end with '/' ", path)
128
+
129
+ del self._children[path]
130
+
131
+ def render(self, request):
132
+ # Check IP filtering if configured
133
+ if self._allowedIpsList is not None:
134
+ clientIp = request.getClientAddress().host
135
+ if clientIp not in self._allowedIpsList:
136
+ request.setResponseCode(403)
137
+ return b'Forbidden: IP address not allowed'
138
+
139
+ # Optionally, Do some checking with userSession.userDetails.group
140
+ # userSession = IUserSession(request.getSession())
141
+
142
+ m = getattr(self, 'render_' + nativeString(request.method), None)
143
+ if not m:
144
+ raise UnsupportedMethod(_computeAllowedMethods(self))
145
+ return m(request)
146
+
147
+ def render_HEAD(self, request):
148
+ return self.render_GET(request)
149
+
150
+ def _gzipIfRequired(self, resource):
151
+ if (not isinstance(resource, EncodingResourceWrapper)
152
+ and hasattr(resource, 'isGzipped')
153
+ and resource.isGzipped):
154
+ return EncodingResourceWrapper(resource, [GzipEncoderFactory()])
155
+ return resource
@@ -0,0 +1,112 @@
1
+ # Copyright (c) 2016-2026 Flying Ion Pty Ltd
2
+ #
3
+ # This software is open source; the MIT license applies.
4
+ # See LICENSE for the full license text.
5
+ #
6
+ # Website : https://flyingion.com
7
+ # Support : contact@flyingion.com
8
+
9
+ import logging
10
+
11
+ from twisted.trial import unittest
12
+ from twisted.web.resource import NoResource
13
+ from twisted.web.test.requesthelper import DummyRequest
14
+ from txhttputil.site.BasicResource import BasicResource
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class MockAddress:
20
+ def __init__(self, host):
21
+ self.host = host
22
+
23
+
24
+ class TestBasicResource(BasicResource):
25
+ def render_GET(self, request):
26
+ return b"GET response"
27
+
28
+ def render_POST(self, request):
29
+ return b"POST response"
30
+
31
+
32
+ class BasicResourceTest(unittest.TestCase):
33
+
34
+ def setUp(self):
35
+ self.resource = TestBasicResource()
36
+
37
+ def testBasicGetChild(self):
38
+ child = self.resource.getChild(b"nonexistent", None)
39
+ self.assertIsInstance(child, NoResource)
40
+
41
+ def testPutAndGetChild(self):
42
+ childResource = TestBasicResource()
43
+ self.resource.putChild(b"child", childResource)
44
+
45
+ retrievedChild = self.resource.getChild(b"child", None)
46
+ self.assertEqual(retrievedChild, childResource)
47
+
48
+ def testDeleteChild(self):
49
+ childResource = TestBasicResource()
50
+ self.resource.putChild(b"child", childResource)
51
+
52
+ self.resource.deleteChild(b"child")
53
+
54
+ retrievedChild = self.resource.getChild(b"child", None)
55
+ self.assertIsInstance(retrievedChild, NoResource)
56
+
57
+ def testRenderGetMethod(self):
58
+ request = DummyRequest([])
59
+ request.method = b"GET"
60
+
61
+ response = self.resource.render(request)
62
+ self.assertEqual(response, b"GET response")
63
+
64
+ def testRenderPostMethod(self):
65
+ request = DummyRequest([])
66
+ request.method = b"POST"
67
+
68
+ response = self.resource.render(request)
69
+ self.assertEqual(response, b"POST response")
70
+
71
+ def testIpFilteringAllowed(self):
72
+ allowedIps = ["127.0.0.1", "192.168.1.100"]
73
+ resource = TestBasicResource(allowedIpsList=allowedIps)
74
+
75
+ request = DummyRequest([])
76
+ request.method = b"GET"
77
+ request.getClientAddress = lambda: MockAddress("127.0.0.1")
78
+
79
+ response = resource.render(request)
80
+ self.assertEqual(response, b"GET response")
81
+
82
+ def testIpFilteringBlocked(self):
83
+ allowedIps = ["127.0.0.1", "192.168.1.100"]
84
+ resource = TestBasicResource(allowedIpsList=allowedIps)
85
+
86
+ request = DummyRequest([])
87
+ request.method = b"GET"
88
+ request.getClientAddress = lambda: MockAddress("10.0.0.1")
89
+
90
+ response = resource.render(request)
91
+ self.assertEqual(response, b"Forbidden: IP address not allowed")
92
+ self.assertEqual(request.responseCode, 403)
93
+
94
+ def testIpFilteringDisabled(self):
95
+ resource = TestBasicResource(allowedIpsList=None)
96
+
97
+ request = DummyRequest([])
98
+ request.method = b"GET"
99
+ request.getClientAddress = lambda: MockAddress("10.0.0.1")
100
+
101
+ response = resource.render(request)
102
+ self.assertEqual(response, b"GET response")
103
+
104
+
105
+ if __name__ == "__main__":
106
+ from twisted.trial import runner, reporter
107
+ import sys
108
+
109
+ trialRunner = runner.TrialRunner(reporter.VerboseTextReporter)
110
+ suite = runner.TestLoader().loadClass(BasicResourceTest)
111
+ result = trialRunner.run(suite)
112
+ sys.exit(not result.wasSuccessful())