├── .coveragerc ├── .gitignore ├── CHANGES.txt ├── CONTRIBUTORS.txt ├── MANIFEST.in ├── README.rst ├── pyramid_ipauth ├── __init__.py ├── tests.py └── utils.py ├── setup.cfg ├── setup.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | omit = pyramid_ipauth/tests.py 4 | 5 | [report] 6 | show_missing = True 7 | 8 | [html] 9 | directory = coverage_html_report -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .hg* 2 | *.pyc 3 | *.egg 4 | *.egg-info 5 | .eggs/ 6 | *.swp 7 | .coverage 8 | .coverage.* 9 | .tox/ 10 | *~ 11 | dist 12 | build 13 | htmlcov 14 | venv/ -------------------------------------------------------------------------------- /CHANGES.txt: -------------------------------------------------------------------------------- 1 | 0.3.3 - 2017-02-02 2 | ================== 3 | 4 | - Fix splitting ipset strings on whitespace; thanks @kaleposhobios 5 | 6 | 0.3.2 - 2017-01-22 7 | ================== 8 | 9 | - Resolve some warnings and clean up some tests; thanks Scott Searcy 10 | 11 | 0.3.1 - 2016-03-18 12 | ================== 13 | 14 | - Fixes for python3 compatibility 15 | 16 | 0.3.0 - 2016-03-18 17 | ================== 18 | 19 | - Add support for python3 20 | 21 | 22 | 0.2.0 - 2013-10-14 23 | ================== 24 | 25 | - Add get_userid and get_principals callback functions; thanks mrijken 26 | - Convert principals into a list if necessary; thanks janakj 27 | 28 | 29 | 0.1.1 - 2012-01-30 30 | ================== 31 | 32 | - Update license to MPL 2.0 33 | 34 | 35 | 0.1.0 - 2011-11-11 36 | ================== 37 | 38 | - Initial release 39 | -------------------------------------------------------------------------------- /CONTRIBUTORS.txt: -------------------------------------------------------------------------------- 1 | List of Contributors 2 | ==================== 3 | 4 | The below-signed are contributors to a code repository that is part of the 5 | project named "pyramid_ipauth". Each below-signed contributor has read, 6 | understand and agrees to the terms of th MPL 2.0 license as of the date beside 7 | his or her name. 8 | 9 | 10 | Contributors 11 | ------------ 12 | 13 | - Marc Rijken , 2013/08/12 14 | - Jan Janak , 2013/09/17 15 | - Aleksander Sukharev , 2016/03/15 16 | - Scott Searcy , 2016/09/19 17 | 18 | 19 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGES.txt 2 | include README.rst 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============== 2 | pyramid_ipauth 3 | ============== 4 | 5 | An authentication policy for Pyramid that sets identity and effective 6 | principals based on the remote IP address of the request. 7 | 8 | 9 | Overview 10 | ======== 11 | 12 | To perform IP-address-based authentication, create an IPAuthenticationPolicy 13 | and specify the target IP range, userid and effective principals. Then set it 14 | as the authentication policy in your configurator:: 15 | 16 | authn_policy = IPAuthenticationPolicy("127.0.*.*", "myuser", ["locals"]) 17 | config.set_authentication_policy(authn_policy) 18 | 19 | This will cause all requests from IP addresses in the 127.0.*.* range to be 20 | authenticated as user "myuser" and have the effective principal "locals". 21 | 22 | It is also possible to specify the configuration options in your deployment 23 | file:: 24 | 25 | [app:pyramidapp] 26 | use = egg:mypyramidapp 27 | 28 | ipauth.ipaddrs = 127.0.0.* 127.0.1.* 29 | ipauth.principals = locals 30 | 31 | You can then simply include the pyramid_ipauth package into your configurator:: 32 | 33 | config.include("pyramid_ipauth") 34 | 35 | It will detect the ipauth settings and construct an appropriate policy. 36 | 37 | Note that this package only supports matching against a single set of IP 38 | addresss. If you need to assign different credentials to different sets 39 | of IP addresses, you can use the pyramid_multiauth package in conjunction 40 | with pyramid_ipauth: 41 | 42 | http://github.com/mozilla-services/pyramid_multiauth 43 | 44 | If you don't want to hard-code the userid or principals at configuration time, 45 | you may specify a "get_userid" and/or "get_principals" callback instead. 46 | 47 | 48 | Specifying IP Addresses 49 | ======================= 50 | 51 | IP addresses can be specified in a variety of forms, including: 52 | 53 | * "all": all possible IPv4 and IPv6 addresses 54 | * "local": all local addresses of the machine 55 | * "A.B.C.D" a single IP address 56 | * "A.B.C.D/N" a network address specification 57 | * "A.B.C.*" a glob matching against all possible numbers 58 | * "A.B.C.D-E" a glob matching against a range of numbers 59 | * a whitespace- or comma-separated string of any of the above 60 | * a netaddr IPAddress, IPRange, IPGlob, IPNetork of IPSet object 61 | * a list, tuple or iterable of any of the above 62 | 63 | 64 | Proxies 65 | ======= 66 | 67 | This module does not respect the X-Forwarded-For header by default, since it 68 | can be spoofed easily by malicious clients. If your server is behind a 69 | trusted proxy that sets the X-Forwarded-For header, you should explicitly 70 | declare the set of trusted proxies like so:: 71 | 72 | IPAuthenticationPolicy("127.0.*.*", 73 | principals=["local"], 74 | proxies = "127.0.0.1") 75 | 76 | The set of trusted proxy addresses can be specified using the same syntax as 77 | the set of IP addresses to authenticate. 78 | -------------------------------------------------------------------------------- /pyramid_ipauth/__init__.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 | # You can obtain one at http://mozilla.org/MPL/2.0/. 4 | """ 5 | IP-based authentication policy for pyramid. 6 | """ 7 | from zope.interface import implementer 8 | 9 | from pyramid.interfaces import IAuthenticationPolicy 10 | from pyramid.security import Everyone, Authenticated 11 | from pyramid.authorization import ACLAuthorizationPolicy 12 | from pyramid.settings import aslist 13 | from pyramid.path import DottedNameResolver 14 | from pyramid.compat import iteritems_, string_types 15 | 16 | from pyramid_ipauth.utils import make_ip_set, check_ip_address, get_ip_address 17 | 18 | 19 | __ver_major__ = 0 20 | __ver_minor__ = 3 21 | __ver_patch__ = 3 22 | __ver_sub__ = "" 23 | __ver_tuple__ = (__ver_major__, __ver_minor__, __ver_patch__, __ver_sub__) 24 | __version__ = "%d.%d.%d%s" % __ver_tuple__ 25 | 26 | 27 | @implementer(IAuthenticationPolicy) 28 | class IPAuthenticationPolicy(object): 29 | """An IP-based authentication policy for pyramid. 30 | 31 | This pyramid authentication policy assigns userid and/or effective 32 | principals based on the originating IP address of the request. 33 | 34 | You must specify a set of IP addresses against which to match, and may 35 | specify a userid and/or list of principals to apply. For example, the 36 | following would authenticate all requests from the 192.168.0.* range as 37 | userid "myuser": 38 | 39 | IPAuthenticationPolicy(["192.168.0.0/24"], "myuser") 40 | 41 | The following would not authenticate as a particular userid, but would add 42 | "local" as an effective principal for the request (along with Everyone 43 | and Authenticated): 44 | 45 | IPAuthenticationPolicy(["127.0.0.0/24"], principals=["local"]) 46 | 47 | By default this policy does not respect the X-Forwarded-For header since 48 | it can be easily spoofed. If you want to respect X-Forwarded-For then you 49 | must specify a list of trusted proxies, and only forwarding declarations 50 | from these proxies will be respected: 51 | 52 | IPAuthenticationPolicy(["192.168.0.0/24"], "myuser", 53 | proxies=["192.168.0.2"]) 54 | 55 | Instead of given the IP addresses, 56 | userid and principals at configuration time. 57 | we can also compute these at runtime. 58 | 59 | def get_userid(ipaddr): 60 | "compute a userid based on the request" 61 | if str(ipaddr).startswith('192'): 62 | return 'LAN-user' 63 | return 'WAN-user' 64 | def get_principals(userid, ipaddr): 65 | "return a list of principals" 66 | if userid == 'WAN-user': 67 | return ['view'] 68 | if userid == 'LAN-user': 69 | return ['view', 'edit'] 70 | return [] 71 | IPAuthenticationPolicy(get_userid=get_userid, 72 | get_principals=get_principals) 73 | 74 | """ 75 | 76 | def __init__(self, ipaddrs, 77 | userid=None, 78 | principals=None, 79 | proxies=None, 80 | get_userid=None, 81 | get_principals=None): 82 | r = DottedNameResolver() 83 | self.get_userid = r.maybe_resolve(get_userid) 84 | self.get_principals = r.maybe_resolve(get_principals) 85 | self.ipaddrs = make_ip_set(ipaddrs) 86 | self.userid = userid 87 | if isinstance(principals, string_types): 88 | self.principals = aslist(principals) 89 | else: 90 | self.principals = principals 91 | self.proxies = make_ip_set(proxies) 92 | 93 | @classmethod 94 | def from_settings(cls, settings={}, prefix="ipauth.", **kwds): 95 | """Construct an IPAuthenticationPolicy from deployment settings.""" 96 | # Grab out all the settings keys that start with our prefix. 97 | ipauth_settings = {} 98 | for name, value in iteritems_(settings): 99 | if not name.startswith(prefix): 100 | continue 101 | ipauth_settings[name[len(prefix):]] = value 102 | # Update with any additional keyword arguments. 103 | ipauth_settings.update(kwds) 104 | # Now look for specific keys of interest. 105 | ipaddrs = ipauth_settings.get("ipaddrs", "") 106 | userid = ipauth_settings.get("userid", None) 107 | principals = aslist(ipauth_settings.get("principals", "")) 108 | proxies = ipauth_settings.get("proxies", None) 109 | get_userid = ipauth_settings.get("get_userid", None) 110 | get_principals = ipauth_settings.get("get_principals", None) 111 | # The constructor uses make_ip_set to parse out strings, 112 | # so we're free to just pass them on in. 113 | return cls(ipaddrs, userid, principals, 114 | proxies, get_userid, get_principals) 115 | 116 | def authenticated_userid(self, request): 117 | return self.unauthenticated_userid(request) 118 | 119 | def unauthenticated_userid(self, request): 120 | if not check_ip_address(request, self.ipaddrs, self.proxies): 121 | return None 122 | if self.get_userid is not None: 123 | userid = self.get_userid(get_ip_address(request, self.proxies)) 124 | else: 125 | userid = self.userid 126 | return userid 127 | 128 | def effective_principals(self, request): 129 | principals = [Everyone] 130 | if not check_ip_address(request, self.ipaddrs, self.proxies): 131 | return principals 132 | 133 | if self.get_userid is not None: 134 | userid = self.get_userid(get_ip_address(request, self.proxies)) 135 | else: 136 | userid = self.userid 137 | 138 | if userid is not None: 139 | principals.insert(0, userid) 140 | principals.append(Authenticated) 141 | 142 | if self.get_principals is not None: 143 | addr = get_ip_address(request, self.proxies) 144 | principals.extend(self.get_principals(userid, addr)) 145 | elif self.principals is not None: 146 | principals.extend(self.principals) 147 | 148 | return principals 149 | 150 | def remember(self, request, principal, **kw): 151 | return [] 152 | 153 | def forget(self, request): 154 | return [] 155 | 156 | 157 | def includeme(config): 158 | """Include default ipauth settings into a pyramid config. 159 | 160 | This function provides a hook for pyramid to include the default settings 161 | for ip-based auth. Activate it like so: 162 | 163 | config.include("pyramid_ipauth") 164 | 165 | This will activate an IPAuthenticationPolicy instance with settings taken 166 | from the the application settings as follows: 167 | 168 | * ipauth.ipaddrs: list of ip addresses to authenticate 169 | * ipauth.userid: the userid as which to authenticate 170 | * ipauth.principals: additional principals as which to authenticate 171 | * ipauth.proxies: list of ip addresses to trust as proxies 172 | * ipauth.get_userid: a (dotted name to a) callable which receive 173 | the ip address to compute the userid 174 | * ipauth.get_principals: a (dotted name to a) callable which receive 175 | the ip address to compute the principals 176 | 177 | IP addresses can be specified in a variety of formats, including single 178 | addresses ("1.2.3.4"), networks ("1.2.3.0/16"), and globs ("1.2.3.*"). 179 | You can also provide a whitespace-separated list of addresses to handle 180 | discontiguous ranges. 181 | """ 182 | # Grab the pyramid-wide settings, to look for any auth config. 183 | settings = config.get_settings().copy() 184 | # Hook up a default AuthorizationPolicy. 185 | # ACLAuthorizationPolicy is usually what you want. 186 | # If the app configures one explicitly then this will get overridden. 187 | authz_policy = ACLAuthorizationPolicy() 188 | config.set_authorization_policy(authz_policy) 189 | # Use the settings to construct an AuthenticationPolicy. 190 | authn_policy = IPAuthenticationPolicy.from_settings(settings) 191 | config.set_authentication_policy(authn_policy) 192 | -------------------------------------------------------------------------------- /pyramid_ipauth/tests.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 | # You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import unittest2 6 | 7 | from netaddr import IPAddress, IPSet, IPRange, IPGlob, IPNetwork 8 | 9 | import pyramid.testing 10 | from pyramid.testing import DummyRequest 11 | from pyramid.security import Everyone, Authenticated 12 | from pyramid.interfaces import IAuthenticationPolicy 13 | 14 | from pyramid_ipauth import IPAuthenticationPolicy, includeme 15 | from pyramid_ipauth.utils import make_ip_set, get_ip_address, check_ip_address 16 | 17 | 18 | class IPAuthPolicyTests(unittest2.TestCase): 19 | 20 | def setUp(self): 21 | self.config = pyramid.testing.setUp() 22 | 23 | def tearDown(self): 24 | pyramid.testing.tearDown() 25 | 26 | def test_remember_forget(self): 27 | policy = IPAuthenticationPolicy(["123.123.0.0/16"], "user") 28 | request = DummyRequest(environ={"REMOTE_ADDR": "192.168.0.1"}) 29 | self.assertEqual(policy.remember(request, "user"), []) 30 | self.assertEqual(policy.forget(request), []) 31 | 32 | def test_remote_addr(self): 33 | policy = IPAuthenticationPolicy(["123.123.0.0/16"], "user") 34 | # Addresses outside the range don't authenticate 35 | request = DummyRequest(environ={"REMOTE_ADDR": "192.168.0.1"}) 36 | self.assertEqual(policy.authenticated_userid(request), None) 37 | # Addresses inside the range do authenticate 38 | request = DummyRequest(environ={"REMOTE_ADDR": "123.123.0.1"}) 39 | self.assertEqual(policy.authenticated_userid(request), "user") 40 | request = DummyRequest(environ={"REMOTE_ADDR": "123.123.1.2"}) 41 | self.assertEqual(policy.authenticated_userid(request), "user") 42 | 43 | def test_noncontiguous_ranges(self): 44 | policy = IPAuthenticationPolicy(["123.123.0.0/16", "124.124.1.0/24"], 45 | "user") 46 | # Addresses outside the range don't authenticate 47 | request = DummyRequest(environ={"REMOTE_ADDR": "192.168.0.1"}) 48 | self.assertEqual(policy.authenticated_userid(request), None) 49 | request = DummyRequest(environ={"REMOTE_ADDR": "124.124.0.1"}) 50 | self.assertEqual(policy.authenticated_userid(request), None) 51 | # Addresses inside the range do authenticate 52 | request = DummyRequest(environ={"REMOTE_ADDR": "123.123.0.1"}) 53 | self.assertEqual(policy.authenticated_userid(request), "user") 54 | request = DummyRequest(environ={"REMOTE_ADDR": "124.124.1.2"}) 55 | self.assertEqual(policy.authenticated_userid(request), "user") 56 | 57 | def test_get_ip_address(self): 58 | # Testing without X-Forwarded-For 59 | request = DummyRequest(environ={"REMOTE_ADDR": "192.168.0.1"}) 60 | self.assertEqual(get_ip_address(request), 61 | IPAddress("192.168.0.1")) 62 | # Testing with X-Forwaded-For and no trusted proxies 63 | request = DummyRequest(environ={"REMOTE_ADDR": "192.168.0.1", 64 | "HTTP_X_FORWARDED_FOR": "123.123.0.1"}) 65 | self.assertEqual(get_ip_address(request), 66 | IPAddress("192.168.0.1")) 67 | # Testing with an untrusted proxy 68 | self.assertEqual(get_ip_address(request, "192.168.1.1"), 69 | IPAddress("192.168.0.1")) 70 | # Testing with a trusted proxy 71 | self.assertEqual(get_ip_address(request, "192.168.0.1"), 72 | IPAddress("123.123.0.1")) 73 | # Testing with a malformed XFF header 74 | request = DummyRequest( 75 | environ={ 76 | "REMOTE_ADDR": "192.168.0.1", 77 | "HTTP_X_FORWARDED_FOR": "124.124.0.1 123.123.0.1"}) 78 | self.assertEqual(get_ip_address(request, "192.168.0.1"), 79 | IPAddress("192.168.0.1")) 80 | # Testing with a trusted proxy and untrusted proxy 81 | request = DummyRequest( 82 | environ={ 83 | "REMOTE_ADDR": "192.168.0.1", 84 | "HTTP_X_FORWARDED_FOR": "124.124.0.1, 123.123.0.1"}) 85 | self.assertEqual(get_ip_address(request, "192.168.0.1"), 86 | IPAddress("123.123.0.1")) 87 | # Testing with several trusted proxies 88 | request = DummyRequest( 89 | environ={ 90 | "REMOTE_ADDR": "192.168.0.1", 91 | "HTTP_X_FORWARDED_FOR": "124.124.0.1, 123.123.0.1"}) 92 | self.assertEqual(get_ip_address(request, "192.168.0.1 123.123.0.1"), 93 | IPAddress("124.124.0.1")) 94 | 95 | def test_check_ip_address(self): 96 | # Testing without X-Forwarded-For 97 | request = DummyRequest(environ={"REMOTE_ADDR": "192.168.0.1"}) 98 | self.assertTrue(check_ip_address(request, "192.168.0.1")) 99 | self.assertTrue(check_ip_address(request, "192.168.0.1/8")) 100 | self.assertFalse(check_ip_address(request, "192.168.0.2")) 101 | 102 | def test_x_forwarded_for(self): 103 | policy = IPAuthenticationPolicy(["123.123.0.0/16"], "user", 104 | proxies=["124.124.0.0/24"]) 105 | # Requests without X-Forwarded-For work as normal 106 | request = DummyRequest(environ={"REMOTE_ADDR": "192.168.0.1"}) 107 | self.assertEqual(policy.authenticated_userid(request), None) 108 | request = DummyRequest(environ={"REMOTE_ADDR": "123.123.0.1"}) 109 | self.assertEqual(policy.authenticated_userid(request), "user") 110 | # Requests with untrusted X-Forwarded-For don't authenticate 111 | request = DummyRequest(environ={"REMOTE_ADDR": "192.168.0.1", 112 | "HTTP_X_FORWARDED_FOR": "123.123.0.1"}) 113 | self.assertEqual(policy.authenticated_userid(request), None) 114 | # Requests from single trusted proxy do authenticate 115 | request = DummyRequest(environ={"REMOTE_ADDR": "124.124.0.1", 116 | "HTTP_X_FORWARDED_FOR": "123.123.0.1"}) 117 | self.assertEqual(policy.authenticated_userid(request), "user") 118 | # Requests from chain of trusted proxies do authenticate 119 | request = DummyRequest( 120 | environ={ 121 | "REMOTE_ADDR": "124.124.0.2", 122 | "HTTP_X_FORWARDED_FOR": "123.123.0.1, 124.124.0.1"}) 123 | self.assertEqual(policy.authenticated_userid(request), "user") 124 | # Requests with untrusted proxy in chain don't authenticate 125 | request = DummyRequest( 126 | environ={ 127 | "REMOTE_ADDR": "124.124.0.1", 128 | "HTTP_X_FORWARDED_FOR": "123.123.0.1, 192.168.0.1"}) 129 | self.assertEqual(policy.authenticated_userid(request), None) 130 | 131 | def test_principals(self): 132 | policy = IPAuthenticationPolicy(["123.123.0.0/16"], 133 | principals="test") 134 | # Addresses outside the range don't get metadata set 135 | request = DummyRequest(environ={"REMOTE_ADDR": "192.168.0.1"}) 136 | self.assertEqual(policy.effective_principals(request), [Everyone]) 137 | # Addresses inside the range do get metadata set 138 | request = DummyRequest(environ={"REMOTE_ADDR": "123.123.0.1"}) 139 | self.assertEqual(policy.effective_principals(request), 140 | [Everyone, "test"]) 141 | policy.userid = "user" 142 | self.assertEqual(policy.effective_principals(request), 143 | ["user", Everyone, Authenticated, "test"]) 144 | 145 | def test_make_ip_set(self): 146 | def is_in(ipaddr, ipset): 147 | ipset = make_ip_set(ipset) 148 | return IPAddress(ipaddr) in ipset 149 | # Test individual IPs 150 | self.assertTrue(is_in("127.0.0.1", "127.0.0.1")) 151 | self.assertFalse(is_in("127.0.0.2", "127.0.0.1")) 152 | # Test globbing 153 | self.assertTrue(is_in("127.0.0.1", "127.0.0.*")) 154 | self.assertTrue(is_in("127.0.1.2", "127.0.*.*")) 155 | self.assertTrue(is_in("127.0.0.1", "127.0.0.*")) 156 | self.assertFalse(is_in("127.0.1.2", "127.0.0.*")) 157 | self.assertTrue(is_in("127.0.0.1", "127.0.0.1-5")) 158 | self.assertTrue(is_in("127.0.0.5", "127.0.0.1-5")) 159 | self.assertFalse(is_in("127.0.0.6", "127.0.0.1-5")) 160 | # Test networks 161 | self.assertTrue(is_in("127.0.0.1", "127.0.0.0/8")) 162 | self.assertTrue(is_in("127.0.0.1", "127.0.0.0/16")) 163 | self.assertTrue(is_in("127.0.0.1", "127.0.0.0/24")) 164 | self.assertFalse(is_in("127.0.1.2", "127.0.0.0/24")) 165 | # Test literal None 166 | self.assertFalse(is_in("127.0.0.1", None)) 167 | # Test special strings 168 | self.assertTrue(is_in("127.0.0.1", "local")) 169 | self.assertTrue(is_in("127.0.0.1", "all")) 170 | GOOGLE_DOT_COM = "74.125.237.20" 171 | self.assertFalse(is_in(GOOGLE_DOT_COM, "local")) 172 | self.assertTrue(is_in(GOOGLE_DOT_COM, "all")) 173 | # Test with a list of stuff 174 | ips = ["127.0.0.1", "127.0.1.*"] 175 | self.assertTrue(is_in("127.0.0.1", ips)) 176 | self.assertTrue(is_in("127.0.1.1", ips)) 177 | self.assertFalse(is_in("127.0.0.2", ips)) 178 | self.assertTrue(is_in("127.0.1.2", ips)) 179 | # Test with a string-list of stuff 180 | ips = "123.123.0.0/16 local" 181 | self.assertTrue(is_in("127.0.0.1", ips)) 182 | self.assertTrue(is_in("127.0.1.1", ips)) 183 | self.assertTrue(is_in("123.123.1.1", ips)) 184 | self.assertFalse(is_in("124.0.0.1", ips)) 185 | # Test with list-splitting edge-cases 186 | self.assertTrue(is_in("127.0.0.1", "127.0.0.2,127.0.0.1")) 187 | self.assertTrue(is_in("127.0.0.1", "127.0.0.2, 127.0.0.1")) 188 | self.assertTrue(is_in("127.0.0.1", "127.0.0.2 127.0.0.1")) 189 | # Test with various strange inputs to the parser 190 | self.assertTrue(is_in("127.0.0.1", IPAddress("127.0.0.1"))) 191 | self.assertTrue(is_in("127.0.0.1", int(IPAddress("127.0.0.1")))) 192 | self.assertTrue(is_in("127.0.0.1", IPNetwork("127.0.0.1/8"))) 193 | self.assertTrue(is_in("127.0.0.1", IPGlob("127.0.0.*"))) 194 | self.assertTrue(is_in("127.0.0.1", IPRange("127.0.0.1", "127.0.0.2"))) 195 | self.assertTrue(is_in("127.0.0.1", IPSet(["127.0.0.1/8"]))) 196 | self.assertFalse(is_in("127.0.0.1", "")) 197 | self.assertFalse(is_in("127.0.0.1", None)) 198 | self.assertRaises(ValueError, is_in, "127.0.0.1", 3.14159) 199 | self.assertRaises(ValueError, is_in, "127.0.0.1", Ellipsis) 200 | 201 | def test_callbacks(self): 202 | def get_userid(ipaddr): 203 | if str(ipaddr).startswith('192'): 204 | return 'LAN-user' 205 | if str(ipaddr).startswith('127'): 206 | return 'localhost-user' 207 | return None 208 | 209 | def get_principals(userid, ipaddr): 210 | principals = { 211 | 'LAN-user': ['view'], 212 | 'localhost-user': ['view', 'edit'], 213 | } 214 | return principals.get(userid, []) 215 | 216 | policy = IPAuthenticationPolicy("all", get_userid=get_userid, 217 | get_principals=get_principals) 218 | # Addresses outside the range don't authenticate 219 | request = DummyRequest(environ={"REMOTE_ADDR": "192.168.0.1"}) 220 | self.assertEqual(policy.unauthenticated_userid(request), "LAN-user") 221 | self.assertEqual(policy.authenticated_userid(request), "LAN-user") 222 | self.assertEqual(policy.effective_principals(request), 223 | ["LAN-user", Everyone, Authenticated, 'view']) 224 | request = DummyRequest(environ={"REMOTE_ADDR": "127.0.0.1"}) 225 | self.assertEqual(policy.unauthenticated_userid(request), 226 | "localhost-user") 227 | self.assertEqual(policy.authenticated_userid(request), 228 | "localhost-user") 229 | self.assertEqual(policy.effective_principals(request), 230 | ["localhost-user", Everyone, Authenticated, 231 | 'view', 'edit']) 232 | request = DummyRequest(environ={"REMOTE_ADDR": "86.8.8.8"}) 233 | self.assertEqual(policy.unauthenticated_userid(request), None) 234 | self.assertEqual(policy.authenticated_userid(request), None) 235 | self.assertEqual(policy.effective_principals(request), [Everyone]) 236 | 237 | def test_from_settings(self): 238 | settings = { 239 | "foo": "bar", 240 | "ipauth.ipaddrs": "123.123.0.1 124.124.0.1/16", 241 | "ipauth.userid": "one", 242 | "ipauth.principals": "two three", 243 | "otherauth.ipaddrs": "127.0.0.*", 244 | "otherauth.userid": "other", 245 | "otherauth.proxies": "127.0.0.1 127.0.0.2", 246 | } 247 | # Try basic instantiation. 248 | auth = IPAuthenticationPolicy.from_settings(settings) 249 | self.assertTrue(IPAddress("123.123.0.1") in auth.ipaddrs) 250 | self.assertEqual(auth.userid, "one") 251 | self.assertEqual(auth.principals, ["two", "three"]) 252 | self.assertEqual(auth.proxies, IPSet([])) 253 | # Try instantiation with custom prefix. 254 | auth = IPAuthenticationPolicy.from_settings(settings, 255 | prefix="otherauth.") 256 | self.assertTrue(IPAddress("127.0.0.1") in auth.ipaddrs) 257 | self.assertEqual(auth.userid, "other") 258 | self.assertEqual(auth.principals, []) 259 | self.assertTrue(IPAddress("127.0.0.1") in auth.proxies) 260 | self.assertTrue(IPAddress("127.0.0.2") in auth.proxies) 261 | self.assertFalse(IPAddress("127.0.0.3") in auth.proxies) 262 | # Try instantiation with extra keywords 263 | auth = IPAuthenticationPolicy.from_settings(settings, 264 | prefix="otherauth.", 265 | userid="overwritten") 266 | self.assertTrue(IPAddress("127.0.0.1") in auth.ipaddrs) 267 | self.assertEqual(auth.userid, "overwritten") 268 | self.assertEqual(auth.principals, []) 269 | self.assertTrue(IPAddress("127.0.0.1") in auth.proxies) 270 | self.assertTrue(IPAddress("127.0.0.2") in auth.proxies) 271 | self.assertFalse(IPAddress("127.0.0.3") in auth.proxies) 272 | 273 | def test_includeme(self): 274 | self.config.add_settings({"ipauth.userid": "user"}) 275 | includeme(self.config) 276 | policy = self.config.registry.getUtility(IAuthenticationPolicy) 277 | self.assertEqual(policy.userid, "user") 278 | -------------------------------------------------------------------------------- /pyramid_ipauth/utils.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 | # You can obtain one at http://mozilla.org/MPL/2.0/. 4 | """ 5 | 6 | Utility functions for pyramid_ipauth 7 | 8 | """ 9 | 10 | import re 11 | import socket 12 | 13 | from netaddr import IPAddress, IPNetwork, IPGlob, IPRange, IPSet 14 | 15 | from pyramid.compat import integer_types, string_types 16 | 17 | # This is used to split a string on an optional comma, 18 | # followed by any amount of whitespace. 19 | _COMMA_OR_WHITESPACE = re.compile(r"(?:,\s*)|(?:\s+)") 20 | 21 | 22 | def get_ip_address(request, proxies=None): 23 | """Get the originating IP address from the given request. 24 | 25 | This function resolves and returns the originating IP address of the 26 | given request, by looking up the REMOTE_ADDR and HTTP_X_FORWARDED_FOR 27 | entries from the request environment. 28 | 29 | By default this function does not make use of the X-Forwarded-For header. 30 | To use it you must specify a set of trusted proxy IP addresses. The 31 | X-Forwarded-For header will then be traversed back through trusted proxies, 32 | stopping either at the first untrusted proxy or at the claimed original IP. 33 | """ 34 | if proxies is None: 35 | proxies = IPSet() 36 | elif not isinstance(proxies, IPSet): 37 | proxies = make_ip_set(proxies) 38 | # Get the chain of proxied IP addresses, most recent proxy last. 39 | addr_chain = [] 40 | try: 41 | xff = request.environ["HTTP_X_FORWARDED_FOR"] 42 | except KeyError: 43 | pass 44 | else: 45 | addr_chain.extend(a.strip() for a in xff.split(",")) 46 | addr_chain.append(request.environ["REMOTE_ADDR"]) 47 | # Pop trusted proxies from the list until we get the original addr, 48 | # or until we hit a malformed or untrusted proxy. 49 | addr = IPAddress(addr_chain.pop()) 50 | while addr_chain: 51 | # If it's not a trusted proxy, stop the chain. 52 | if addr not in proxies: 53 | break 54 | # If next is a malformed IP address, stop the chain. 55 | if len(addr_chain[-1].split()) > 1: 56 | break 57 | addr = IPAddress(addr_chain.pop()) 58 | return addr 59 | 60 | 61 | def check_ip_address(request, ipaddrs, proxies=None): 62 | """Check whether a request originated within the given ip address set. 63 | 64 | This function checks whether the originating IP address of the request 65 | is within the given set of IP addresses, returning True if it is and False 66 | if not. 67 | 68 | By default this function does not make use of the X-Forwarded-For header. 69 | To use it you must specify a set of trusted proxy IP addresses which will 70 | be passed on to the get_ip_address function. 71 | """ 72 | if not isinstance(ipaddrs, IPSet): 73 | ipaddrs = make_ip_set(ipaddrs) 74 | ipaddr = get_ip_address(request, proxies) 75 | return (ipaddr in ipaddrs) 76 | 77 | 78 | def make_ip_set(ipaddrs): 79 | """Parse a variety of IP specifications into an IPSet object. 80 | 81 | This is a convenience function that allows you to specify a set of 82 | IP addresses in a variety of ways: 83 | 84 | * as an IPSet, IPAddress, IPNetwork, IPGlob or IPRange object 85 | * as the literal None for the empty set 86 | * as an int parsable by IPAddress 87 | * as a string parsable by parse_ip_set 88 | * as an iterable of IP specifications 89 | 90 | """ 91 | # If it's already an IPSet, well, that's easy. 92 | if isinstance(ipaddrs, IPSet): 93 | return ipaddrs 94 | # None represents the empty set. 95 | if ipaddrs is None: 96 | return IPSet() 97 | # Integers represent a single address. 98 | if isinstance(ipaddrs, integer_types): 99 | return IPSet((IPAddress(ipaddrs),)) 100 | # Strings get parsed as per parse_ip_set 101 | if isinstance(ipaddrs, string_types): 102 | return parse_ip_set(ipaddrs) 103 | # Other netaddr types can be converted into a set. 104 | if isinstance(ipaddrs, (IPAddress, IPNetwork)): 105 | return IPSet((ipaddrs,)) 106 | if isinstance(ipaddrs, (IPGlob, IPRange)): 107 | return IPSet(ipaddrs.cidrs()) 108 | # Anything iterable can be mapped over and unioned. 109 | try: 110 | ipspecs = iter(ipaddrs) 111 | except Exception: 112 | pass 113 | else: 114 | ipset = IPSet() 115 | for ipspec in ipspecs: 116 | ipset |= make_ip_set(ipspec) 117 | return ipset 118 | # Anything else is an error 119 | raise ValueError("can't convert to IPSet: %r" % (ipaddrs,)) 120 | 121 | 122 | def parse_ip_set(ipaddrs): 123 | """Parse a string specification into an IPSet. 124 | 125 | This function takes a string representing a set of IP addresses and 126 | parses it into an IPSet object. Acceptable formats for the string 127 | include: 128 | 129 | * "all": all possible IPv4 and IPv6 addresses 130 | * "local": all local addresses of the machine 131 | * "A.B.C.D" a single IP address 132 | * "A.B.C.D/N" a network address specification 133 | * "A.B.C.*" a glob matching against all possible numbers 134 | * "A.B.C.D-E" a glob matching against a range of numbers 135 | * a whitespace- or comma-separated string of the above 136 | 137 | """ 138 | ipset = IPSet() 139 | ipaddrs = ipaddrs.lower().strip() 140 | if not ipaddrs: 141 | return ipset 142 | for ipspec in _COMMA_OR_WHITESPACE.split(ipaddrs): 143 | # The string "local" maps to all local addresses on the machine. 144 | if ipspec == "local": 145 | ipset.add(IPNetwork("127.0.0.0/8")) 146 | for addr in get_local_ip_addresses(): 147 | ipset.add(addr) 148 | # The string "all" maps to app IPv4 and IPv6 addresses. 149 | elif ipspec == "all": 150 | ipset.add(IPNetwork("0.0.0.0/0")) 151 | ipset.add(IPNetwork("::")) 152 | # Strings containing a "/" are assumed to be network specs 153 | elif "/" in ipspec: 154 | ipset.add(IPNetwork(ipspec)) 155 | # Strings containing a "*" or "-" are assumed to be glob patterns 156 | elif "*" in ipspec or "-" in ipspec: 157 | for cidr in IPGlob(ipspec).cidrs(): 158 | ipset.add(cidr) 159 | # Anything else must be a single address 160 | else: 161 | ipset.add(IPAddress(ipspec)) 162 | return ipset 163 | 164 | 165 | def get_local_ip_addresses(): 166 | """Iterator yielding all local IP addresses on the machine.""" 167 | # XXX: how can we enumerate all interfaces on the machine? 168 | # I don't really want to shell out to `ifconfig` 169 | # Sadly this is not guaranteed to succeed. 170 | try: 171 | for addr in socket.gethostbyname_ex(socket.gethostname())[2]: 172 | yield IPAddress(addr) 173 | except socket.gaierror: 174 | pass 175 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [flake8] 5 | exclude = pyramid_ipauth/tests.py 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | from setuptools import setup, find_packages 4 | 5 | here = os.path.abspath(os.path.dirname(__file__)) 6 | 7 | with open(os.path.join(here, 'README.rst')) as f: 8 | README = f.read() 9 | 10 | with open(os.path.join(here, 'CHANGES.txt')) as f: 11 | CHANGES = f.read() 12 | 13 | requires = ['pyramid>=1.3.0', 'netaddr', 'unittest2'] 14 | 15 | setup(name='pyramid_ipauth', 16 | version='0.3.3', 17 | description='pyramid_ipauth', 18 | long_description=README + '\n\n' + CHANGES, 19 | classifiers=[ 20 | "Programming Language :: Python", 21 | "Framework :: Pylons", 22 | "Topic :: Internet :: WWW/HTTP", 23 | "Programming Language :: Python", 24 | "Programming Language :: Python :: 2.6", 25 | "Programming Language :: Python :: 2.7", 26 | "Programming Language :: Python :: 3.1", 27 | "Programming Language :: Python :: 3.2", 28 | "Programming Language :: Python :: 3.3", 29 | "Programming Language :: Python :: 3.4", 30 | "Programming Language :: Python :: 3.5", 31 | ], 32 | author='Mozilla Services', 33 | author_email='services-dev@mozilla.org', 34 | url='https://github.com/mozilla-services/pyramid_ipauth', 35 | keywords='web pyramid pylons authentication', 36 | packages=find_packages(), 37 | include_package_data=True, 38 | zip_safe=False, 39 | install_requires=requires, 40 | tests_require=requires, 41 | test_suite="pyramid_ipauth", 42 | paster_plugins=['pyramid']) 43 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = coverage-clean, py{27,34,35}, flake8, coverage-report 3 | 4 | [testenv] 5 | deps = 6 | coverage 7 | unittest2 8 | commands = coverage run --parallel -m unittest discover {posargs} 9 | 10 | [testenv:flake8] 11 | skip_install = true 12 | deps = flake8 13 | commands = flake8 pyramid_ipauth 14 | 15 | [testenv:coverage-clean] 16 | deps = coverage 17 | skip_install = true 18 | commands = coverage erase 19 | 20 | [testenv:coverage-report] 21 | deps = coverage 22 | skip_install = true 23 | commands = 24 | coverage combine 25 | coverage report --------------------------------------------------------------------------------