├── .flake8 ├── .github └── workflows │ └── main.yml ├── .gitignore ├── CHANGELOG.rst ├── MANIFEST.in ├── README.rst ├── devpi_lockdown ├── __init__.py ├── main.py └── templates │ ├── login.pt │ └── logout.pt ├── setup.cfg ├── setup.py ├── tests ├── conftest.py ├── test_functional.py └── test_lockdown.py └── tox.ini /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E501 3 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "CI" 3 | 4 | on: 5 | push: 6 | 7 | jobs: 8 | tests: 9 | name: "Python ${{ matrix.python-version }} (${{ matrix.tox-envs }})" 10 | runs-on: "ubuntu-latest" 11 | env: 12 | PY_COLORS: 1 13 | 14 | strategy: 15 | matrix: 16 | include: 17 | - python-version: "3.9" 18 | tox-envs: "py39" 19 | - python-version: "3.13" 20 | tox-envs: "py313" 21 | 22 | steps: 23 | - uses: "actions/checkout@v4" 24 | - uses: "actions/setup-python@v5" 25 | with: 26 | python-version: "${{ matrix.python-version }}" 27 | - name: "Install dependencies" 28 | run: | 29 | set -xe -o nounset 30 | python -VV 31 | python -m site 32 | python -m pip install --upgrade pip setuptools wheel 33 | python -m pip install --upgrade virtualenv tox 34 | 35 | - name: "Run tox targets for ${{ matrix.python-version }} (${{ matrix.tox-envs }})" 36 | run: | 37 | set -xe -o nounset 38 | python -m tox -a -vv 39 | python -m tox -v -e ${{ matrix.tox-envs }} -- -v --color=yes 40 | 41 | flake8: 42 | env: 43 | PY_COLORS: 1 44 | 45 | runs-on: "ubuntu-latest" 46 | 47 | steps: 48 | - uses: "actions/checkout@v4" 49 | - uses: "actions/setup-python@v5" 50 | with: 51 | python-version: "3.x" 52 | - name: "Install dependencies" 53 | shell: "bash" 54 | run: | 55 | set -xe -o nounset 56 | python -VV 57 | python -m site 58 | python -m pip install --upgrade pip flake8 setuptools wheel 59 | - name: "Run flake8" 60 | shell: "bash" 61 | run: | 62 | set -xe -o nounset 63 | flake8 --config .flake8 devpi_lockdown tests 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.sublime-* 3 | .cache/ 4 | /.coverage 5 | /.tox/ 6 | /bin/ 7 | /dist/ 8 | /htmlcov/ 9 | /include/ 10 | /lib/ 11 | /tmp/ 12 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 3.0.0 - Unreleased 5 | ------------------ 6 | 7 | - Require devpi-server >= 6.10.0. 8 | 9 | - Added support for Python 3.12 and 3.13. 10 | 11 | - Dropped Python 3.6, 3.7 and 3.8 support. 12 | 13 | 14 | 2.0.0 - 2021-05-16 15 | ------------------ 16 | 17 | .. note:: The nginx configuration has changed from 1.x. 18 | 19 | - Dropped Python 2.7, 3.4 and 3.5 support. 20 | 21 | - Support for devpi-server 6.0.0. 22 | 23 | - Redirect back to original URL after login. 24 | 25 | - With devpi-server 6.0.0 the ``devpi-gen-config`` script 26 | creates a ``nginx-devpi-lockdown.conf``. 27 | 28 | - Automatically allow locations required for login page. 29 | 30 | - Show error message for invalid credentials. 31 | 32 | - Support Pyramid 2.0. 33 | 34 | 35 | 1.0.1 - 2018-11-16 36 | ------------------ 37 | 38 | - Fix import for Pyramid >= 1.10.0. 39 | 40 | - Add /+static to configuration 41 | 42 | - Lock down everything by default in the configuration and only allow the 43 | necessary locations 44 | 45 | 46 | 1.0.0 - 2017-03-10 47 | ------------------ 48 | 49 | - initial release 50 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.ini *.rst 2 | recursive-include devpi_lockdown/templates *.pt 3 | recursive-include tests *.py 4 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | devpi-lockdown: tools to enable authentication for read access 2 | ============================================================== 3 | 4 | This plugin adds some views to allow locking down read access to devpi. 5 | 6 | Only tested with nginx so far. 7 | 8 | 9 | Installation 10 | ------------ 11 | 12 | ``devpi-lockdown`` needs to be installed alongside ``devpi-server``. 13 | 14 | You can install it with:: 15 | 16 | pip install devpi-lockdown 17 | 18 | 19 | Usage 20 | ----- 21 | 22 | To lock down read access to devpi, you need a proxy in front of devpi which can use the provided views to limit access. 23 | 24 | 25 | The views are: 26 | 27 | /+authcheck 28 | 29 | This returns ``200`` when the user is authenticated or ``401`` if not. 30 | It uses the regular devpi credential checks and an additional credential check using a cookie provided by ``devpi-lockdown`` to allow login with a browser. 31 | 32 | /+login 33 | 34 | A plain login form to allow access via browsers for use with ``devpi-web``. 35 | 36 | /+logout 37 | 38 | Drops the authentication cookie. 39 | 40 | 41 | For nginx the `auth_request`_ module is required. 42 | You should use the ``devpi-genconfig`` script to generate your nginx configuration. 43 | With devpi-server 6.0.0 or newer an ``nginx-devpi-lockdown.conf`` should have been generated. 44 | If not, then you need to add the following to your server block before the first location block: 45 | 46 | .. code-block:: nginx 47 | 48 | # this redirects to the login view when not logged in 49 | recursive_error_pages on; 50 | error_page 401 = @error401; 51 | location @error401 { 52 | return 302 /+login?goto_url=$request_uri; 53 | } 54 | 55 | # lock down everything by default 56 | auth_request /+authcheck; 57 | 58 | # the location to check whether the provided infos authenticate the user 59 | location = /+authcheck { 60 | internal; 61 | 62 | proxy_pass_request_body off; 63 | proxy_set_header Content-Length ""; 64 | proxy_set_header X-Original-URI $request_uri; 65 | proxy_set_header X-outside-url $scheme://$http_host; # copy the value from your existing configuration 66 | proxy_set_header X-Real-IP $remote_addr; # copy the value from your existing configuration 67 | proxy_pass http://localhost:3141; # copy the value from your existing configuration 68 | } 69 | 70 | .. _auth_request: http://nginx.org/en/docs/http/ngx_http_auth_request_module.html 71 | -------------------------------------------------------------------------------- /devpi_lockdown/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '3.0.0.dev0' 2 | -------------------------------------------------------------------------------- /devpi_lockdown/main.py: -------------------------------------------------------------------------------- 1 | from devpi_common.url import URL 2 | from devpi_server import __version__ as devpiserver_version 3 | from pluggy import HookimplMarker 4 | from pkg_resources import parse_version 5 | from pyramid.interfaces import IRequestExtensions 6 | from pyramid.interfaces import IRootFactory 7 | from pyramid.interfaces import IRoutesMapper 8 | from pyramid.httpexceptions import HTTPForbidden 9 | from pyramid.httpexceptions import HTTPFound, HTTPOk, HTTPUnauthorized 10 | try: 11 | from pyramid.interfaces import IAuthenticationPolicy 12 | except ImportError: 13 | IAuthenticationPolicy = object() 14 | try: 15 | from pyramid.interfaces import ISecurityPolicy 16 | except ImportError: 17 | ISecurityPolicy = object() 18 | from pyramid.request import Request 19 | from pyramid.request import apply_request_extensions 20 | from pyramid.threadlocal import RequestContext 21 | from pyramid.traversal import DefaultRootFactory 22 | try: 23 | from pyramid.util import SimpleSerializer 24 | except ImportError: 25 | from pyramid.authentication import _SimpleSerializer as SimpleSerializer 26 | from pyramid.view import view_config 27 | from urllib.parse import quote as url_quote 28 | from urllib.parse import unquote as url_unquote 29 | from webob.cookies import CookieProfile 30 | import re 31 | 32 | 33 | devpiserver_hookimpl = HookimplMarker("devpiserver") 34 | devpiserver_version = parse_version(devpiserver_version) 35 | 36 | is_atleast_server6 = (devpiserver_version >= parse_version("6dev")) 37 | 38 | 39 | def includeme(config): 40 | config.add_route( 41 | "/+authcheck", 42 | "/+authcheck") 43 | config.add_route( 44 | "login", 45 | "/+login", 46 | accept="text/html") 47 | config.add_route( 48 | "logout", 49 | "/+logout", 50 | accept="text/html") 51 | config.scan() 52 | 53 | 54 | def find_injection_index(nginx_lines): 55 | # find first location block 56 | for index, line in enumerate(nginx_lines): 57 | if line.strip().startswith("location"): 58 | break 59 | # go back until we have a non empty non comment line 60 | for index in range(index - 1, 0, -1): 61 | if nginx_lines[index].strip().startswith("#"): 62 | continue 63 | if not nginx_lines[index].strip(): 64 | continue 65 | return index + 1 66 | 67 | 68 | nginx_template = """ 69 | # this redirects to the login view when not logged in 70 | recursive_error_pages on; 71 | error_page 401 = @error401; 72 | location @error401 {{ 73 | return 302 /+login?goto_url=$request_uri; 74 | }} 75 | 76 | # lock down everything by default 77 | auth_request /+authcheck; 78 | 79 | # the location to check whether the provided infos authenticate the user 80 | location = /+authcheck {{ 81 | internal; 82 | 83 | proxy_pass_request_body off; 84 | proxy_set_header Content-Length ""; 85 | proxy_set_header X-Original-URI $request_uri; 86 | {x_outside_url} 87 | {x_real_ip} 88 | {proxy_pass} 89 | }} 90 | """.rstrip() 91 | 92 | 93 | def _inject_lockdown_config(nginx_lines): 94 | # inject our parts before the first location block 95 | index = find_injection_index(nginx_lines) 96 | 97 | def find_line(content): 98 | regexp = re.compile(content, re.I) 99 | for line in nginx_lines: 100 | if regexp.search(line): 101 | return "%s # same as in @proxy_to_app below" % line.strip() 102 | return "couldn't find %r" % content 103 | 104 | nginx_lines[index:index] = nginx_template.format( 105 | x_outside_url=find_line("proxy_set_header.+x-outside-url"), 106 | x_real_ip=find_line("proxy_set_header.+x-real-ip"), 107 | proxy_pass=find_line("proxy_pass")).splitlines() 108 | 109 | 110 | @devpiserver_hookimpl(optionalhook=True) 111 | def devpiserver_genconfig(tw, config, argv, writer): 112 | from devpi_server.genconfig import gen_nginx 113 | 114 | # first get the regular nginx config 115 | nginx_lines = [] 116 | 117 | def my_writer(basename, content): 118 | nginx_lines.extend(content.splitlines()) 119 | 120 | gen_nginx(tw, config, argv, my_writer) 121 | _inject_lockdown_config(nginx_lines) 122 | 123 | # and write it out 124 | nginxconf = "\n".join(nginx_lines) 125 | writer("nginx-devpi-lockdown.conf", nginxconf) 126 | 127 | 128 | @devpiserver_hookimpl 129 | def devpiserver_pyramid_configure(config, pyramid_config): 130 | # by using include, the package name doesn't need to be set explicitly 131 | # for registrations of static views etc 132 | pyramid_config.include('devpi_lockdown.main') 133 | 134 | 135 | @devpiserver_hookimpl 136 | def devpiserver_get_credentials(request): 137 | """Extracts username and password from cookie. 138 | 139 | Returns a tuple with (username, password) if credentials could be 140 | extracted, or None if no credentials were found. 141 | """ 142 | cookie = request.cookies.get('auth_tkt') 143 | if cookie is None: 144 | return 145 | token = url_unquote(cookie) 146 | try: 147 | username, password = token.split(':', 1) 148 | except ValueError: # not enough values to unpack 149 | return None 150 | return username, password 151 | 152 | 153 | @devpiserver_hookimpl(optionalhook=True) 154 | def devpiserver_authcheck_always_ok(request): 155 | route = request.matched_route 156 | if route and route.name.endswith('/+api'): 157 | return True 158 | if route and route.name in ('/+login', 'login', 'logout'): 159 | return True 160 | if route and '+static' in route.name and '/+static' in request.url: 161 | return True 162 | if route and '+theme-static' in route.name and '/+theme-static' in request.url: 163 | return True 164 | 165 | 166 | @devpiserver_hookimpl(optionalhook=True) 167 | def devpiserver_authcheck_unauthorized(request): 168 | if not request.authenticated_userid: 169 | return True 170 | 171 | 172 | def _auth_check_request(request): 173 | if devpiserver_authcheck_always_ok(request=request): 174 | request.log.debug( 175 | "Authcheck always OK for %s (%s)", 176 | request.url, request.matched_route.name) 177 | return HTTPOk() 178 | if not devpiserver_authcheck_unauthorized(request=request): 179 | request.log.debug( 180 | "Authcheck OK for %s (%s)", 181 | request.url, request.matched_route.name) 182 | return HTTPOk() 183 | request.log.debug( 184 | "Authcheck Unauthorized for %s (%s)", 185 | request.url, request.matched_route.name) 186 | user_agent = request.user_agent or "" 187 | if 'devpi-client' in user_agent: 188 | # devpi-client needs to know for proper error messages 189 | return HTTPForbidden() 190 | return HTTPUnauthorized() 191 | 192 | 193 | @view_config(route_name="/+authcheck") 194 | def authcheck_view(context, request): 195 | routes_mapper = request.registry.getUtility(IRoutesMapper) 196 | root_factory = request.registry.queryUtility( 197 | IRootFactory, default=DefaultRootFactory) 198 | request_extensions = request.registry.getUtility(IRequestExtensions) 199 | url = request.headers.get('x-original-uri', request.url) 200 | orig_request = Request.blank(url, headers=request.headers) 201 | orig_request.log = request.log 202 | orig_request.registry = request.registry 203 | if request_extensions: 204 | apply_request_extensions( 205 | orig_request, extensions=request_extensions) 206 | info = routes_mapper(orig_request) 207 | (orig_request.matchdict, orig_request.matched_route) = ( 208 | info['match'], info['route']) 209 | root_factory = orig_request.matched_route.factory or root_factory 210 | orig_request.context = root_factory(orig_request) 211 | with RequestContext(orig_request): 212 | return _auth_check_request(orig_request) 213 | 214 | 215 | def get_cookie_profile(request, max_age=0): 216 | return CookieProfile( 217 | 'auth_tkt', 218 | httponly=True, 219 | max_age=max_age, 220 | secure=request.scheme == 'https', 221 | serializer=SimpleSerializer()).bind(request) 222 | 223 | 224 | @view_config( 225 | route_name="login", 226 | renderer="templates/login.pt") 227 | def login_view(context, request): 228 | policy = request.registry.queryUtility(IAuthenticationPolicy) 229 | if policy is None: 230 | policy = request.registry.getUtility(ISecurityPolicy) 231 | error = None 232 | if 'submit' in request.POST: 233 | user = request.POST['username'] 234 | password = request.POST['password'] 235 | if is_atleast_server6: 236 | token = policy.auth.new_proxy_auth(user, password, request=request) 237 | else: 238 | token = policy.auth.new_proxy_auth(user, password) 239 | if token: 240 | profile = get_cookie_profile( 241 | request, 242 | token['expiration']) 243 | cookie_value = url_quote("%s:%s" % (user, token['password'])) 244 | # set the credentials on the current request 245 | request.cookies[profile.cookie_name] = cookie_value 246 | # coherence check of the generated credentials 247 | if user != request.authenticated_userid: 248 | request.response.status_code = 401 249 | error = "user %r could not be authenticated" % user 250 | return dict(error=error) 251 | # it is possible that a plugin removes the permission to login 252 | # the permission was added in 6.0.0 253 | if is_atleast_server6 and not request.has_permission('user_login'): 254 | request.response.status_code = 401 255 | error = ( 256 | "user %r has no permission to login with the " 257 | "provided credentials" % user) 258 | return dict(error=error) 259 | headers = profile.get_headers(cookie_value) 260 | app_url = URL(request.application_url) 261 | url = app_url.joinpath(request.GET.get('goto_url')) 262 | # plus signs are urldecoded to a space, this reverses that 263 | url = url.replace(path=url.path.replace('/ ', '/+')) 264 | if app_url.netloc != url.netloc or app_url.scheme != url.scheme: 265 | # prevent abuse 266 | url = request.route_url('/') 267 | else: 268 | url = url.url 269 | return HTTPFound(location=url, headers=headers) 270 | else: 271 | request.response.status_code = 401 272 | error = "Invalid credentials" 273 | return dict(error=error) 274 | 275 | 276 | @view_config( 277 | route_name="logout", 278 | request_method="GET", 279 | renderer="templates/logout.pt") 280 | def logout_get_view(context, request): 281 | return dict() 282 | 283 | 284 | @view_config( 285 | route_name="logout", 286 | request_method="POST", 287 | is_mutating=False) 288 | def logout_post_view(context, request): 289 | profile = get_cookie_profile(request) 290 | headers = profile.get_headers(None) 291 | return HTTPFound(location=request.route_url('/'), headers=headers) 292 | -------------------------------------------------------------------------------- /devpi_lockdown/templates/login.pt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | devpi - Login 5 | 6 | 7 | 8 | 9 | 10 |
11 |

${error}

12 |
13 | 14 | 15 | 16 | 17 | 18 |
19 |
20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /devpi_lockdown/templates/logout.pt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | devpi - Logout 5 | 6 | 7 | 8 | 9 | 10 |
11 |

12 |
13 | 14 |
15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [devpi:upload] 2 | formats = sdist.tgz,bdist_wheel 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | import os 3 | 4 | 5 | def get_version(path): 6 | fn = os.path.join( 7 | os.path.dirname(os.path.abspath(__file__)), 8 | path, "__init__.py") 9 | with open(fn) as f: 10 | for line in f: 11 | if '__version__' in line: 12 | parts = line.split("=") 13 | return parts[1].split("'")[1] 14 | 15 | 16 | here = os.path.abspath(os.path.dirname(__file__)) 17 | README = open(os.path.join(here, 'README.rst')).read() 18 | CHANGELOG = open(os.path.join(here, 'CHANGELOG.rst')).read() 19 | 20 | 21 | setup( 22 | name="devpi-lockdown", 23 | description="devpi-lockdown: tools to enable authentication for read access", 24 | long_description=README + "\n\n" + CHANGELOG, 25 | url="https://github.com/devpi/devpi-lockdown", 26 | version=get_version("devpi_lockdown"), 27 | maintainer="Florian Schulze", 28 | maintainer_email="mail@florian-schulze.net", 29 | license="MIT", 30 | classifiers=[ 31 | "Environment :: Web Environment", 32 | "Intended Audience :: Developers", 33 | "Intended Audience :: System Administrators", 34 | "License :: OSI Approved :: MIT License", 35 | "Programming Language :: Python"] + [ 36 | "Programming Language :: Python :: %s" % x 37 | for x in "3 3.9 3.10 3.11 3.12 3.13".split()], 38 | entry_points={ 39 | 'devpi_server': [ 40 | "devpi-lockdown = devpi_lockdown.main"]}, 41 | install_requires=[ 42 | 'devpi-server>=6.10.0', 43 | 'devpi-web'], 44 | extras_require={ 45 | 'tests': [ 46 | 'webtest', 47 | 'mock', 48 | 'devpi-client', 49 | 'pytest', 50 | 'pytest-cov']}, 51 | include_package_data=True, 52 | python_requires='>=3.9', 53 | zip_safe=False, 54 | packages=['devpi_lockdown']) 55 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from devpi_common.url import URL 2 | from pathlib import Path 3 | import os 4 | import pytest 5 | import re 6 | import subprocess 7 | import sys 8 | import textwrap 9 | 10 | 11 | pytest_plugins = ["pytest_devpi_server", "test_devpi_server.plugin"] 12 | 13 | 14 | phase_report_key = pytest.StashKey() 15 | 16 | 17 | @pytest.hookimpl(wrapper=True, tryfirst=True) 18 | def pytest_runtest_makereport(item, call): 19 | rep = yield 20 | item.stash.setdefault(phase_report_key, {})[rep.when] = rep 21 | return rep 22 | 23 | 24 | @pytest.fixture(scope="class") 25 | def adjust_nginx_conf_content(nginx_path): 26 | def adjust_nginx_conf_content(content): 27 | listen = re.search(r'listen \d+;', content).group(0) 28 | new_content = nginx_path.joinpath('nginx-devpi-lockdown.conf').read_text() 29 | new_content = new_content.replace('listen 80;', listen) 30 | new_content = f"proxy_temp_path tmp;\n{new_content}" 31 | new_content = f"client_body_temp_path tmp;\n{new_content}" 32 | return new_content 33 | return adjust_nginx_conf_content 34 | 35 | 36 | @pytest.fixture 37 | def xom(request, makexom): 38 | import devpi_lockdown.main 39 | import devpi_web.main 40 | xom = makexom(plugins=[ 41 | (devpi_web.main, None), 42 | (devpi_lockdown.main, None)]) 43 | from devpi_server.main import set_default_indexes 44 | with xom.keyfs.write_transaction(): 45 | set_default_indexes(xom.model) 46 | return xom 47 | 48 | 49 | @pytest.fixture(scope="class") 50 | def nginx_path(request): 51 | try: 52 | server_path = request.getfixturevalue("server_path") 53 | except pytest.FixtureLookupError: 54 | server_path = Path(request.getfixturevalue("server_directory")) 55 | return server_path / "gen-config" 56 | 57 | 58 | @pytest.fixture 59 | def url_of_liveserver(request, nginx_path, nginx_host_port): 60 | (host, port) = nginx_host_port 61 | yield URL(f'http://{host}:{port}') 62 | key = request.node.stash[phase_report_key] 63 | if (result := key.get('call')) is not None and result.outcome == 'failed': 64 | if (access_log := nginx_path / 'nginx_access.log').exists(): 65 | print(access_log.read_text()) 66 | if (error_log := nginx_path / 'nginx_error.log').exists(): 67 | print(error_log.read_text()) 68 | 69 | 70 | @pytest.fixture 71 | def cmd_devpi(tmpdir, monkeypatch): 72 | """ execute devpi subcommand in-process (with fresh init) """ 73 | from devpi.main import initmain 74 | 75 | def ask_confirm(msg): 76 | print("%s: yes" % msg) 77 | return True 78 | 79 | clientdir = tmpdir.join("client") 80 | 81 | def run_devpi(*args, **kwargs): 82 | callargs = [] 83 | for arg in ["devpi", "--clientdir", clientdir] + list(args): 84 | if isinstance(arg, URL): 85 | arg = arg.url 86 | callargs.append(str(arg)) 87 | print("*** inline$ %s" % " ".join(callargs)) 88 | hub, method = initmain(callargs) 89 | monkeypatch.setattr(hub, "ask_confirm", ask_confirm) 90 | expected = kwargs.get("code", None) 91 | try: 92 | method(hub, hub.args) 93 | except SystemExit as sysex: 94 | hub.sysex = sysex 95 | if expected is None or expected < 0 or expected >= 400: 96 | # we expected an error or nothing, don't raise 97 | pass 98 | else: 99 | raise 100 | finally: 101 | hub.close() 102 | if expected is not None: 103 | if expected == -2: # failed-to-start 104 | assert hasattr(hub, "sysex") 105 | elif isinstance(expected, list): 106 | assert hub._last_http_stati == expected 107 | else: 108 | if not isinstance(expected, tuple): 109 | expected = (expected, ) 110 | if hub._last_http_status not in expected: 111 | pytest.fail( 112 | "got http code %r, expected %r" % ( 113 | hub._last_http_status, expected)) 114 | return hub 115 | 116 | run_devpi.clientdir = clientdir 117 | return run_devpi 118 | 119 | 120 | @pytest.fixture 121 | def devpi_username(): 122 | attrname = '_count' 123 | count = getattr(devpi_username, attrname, 0) 124 | setattr(devpi_username, attrname, count + 1) 125 | return "user%d" % count 126 | 127 | 128 | @pytest.fixture 129 | def devpi(capfd, cmd_devpi, devpi_username, url_of_liveserver): 130 | cmd_devpi("use", url_of_liveserver.url, code=200) 131 | (out, err) = capfd.readouterr() 132 | cmd_devpi("login", "root", "--password", "", code=200) 133 | (out, err) = capfd.readouterr() 134 | cmd_devpi("index", "root/pypi", "mirror_no_project_list=true", "mirror_use_external_urls=true", code=200) 135 | (out, err) = capfd.readouterr() 136 | cmd_devpi("user", "-c", devpi_username, "password=123", "email=123", code=201) 137 | (out, err) = capfd.readouterr() 138 | cmd_devpi("login", devpi_username, "--password", "123", code=200) 139 | (out, err) = capfd.readouterr() 140 | cmd_devpi("index", "-c", "dev", "bases=root/pypi", code=200) 141 | (out, err) = capfd.readouterr() 142 | cmd_devpi("use", "dev", code=200) 143 | (out, err) = capfd.readouterr() 144 | cmd_devpi.user = devpi_username 145 | return cmd_devpi 146 | 147 | 148 | def _path_parts(path): 149 | parts = [] 150 | while path: 151 | folder, name = os.path.split(path) 152 | if folder == path: # root folder 153 | folder, name = name, folder 154 | if name: 155 | parts.append(name) 156 | path = folder 157 | parts.reverse() 158 | return parts 159 | 160 | 161 | def _filedefs_contains(base, filedefs, path): 162 | """ 163 | whether `filedefs` defines a file/folder with the given `path` 164 | 165 | `path`, if relative, will be interpreted relative to the `base` folder, and 166 | whether relative or not, must refer to either the `base` folder or one of 167 | its direct or indirect children. The base folder itself is considered 168 | created if the filedefs structure is not empty. 169 | 170 | """ 171 | unknown = object() 172 | base = Path(base) 173 | path = base / path 174 | 175 | path_rel_parts = _path_parts(path.relative_to(base)) 176 | for part in path_rel_parts: 177 | if not isinstance(filedefs, dict): 178 | return False 179 | filedefs = filedefs.get(part, unknown) 180 | if filedefs is unknown: 181 | return False 182 | return path_rel_parts or path == base and filedefs 183 | 184 | 185 | def create_files(base, filedefs): 186 | for key, value in filedefs.items(): 187 | if isinstance(value, dict): 188 | create_files(base.ensure(key, dir=1), value) 189 | elif isinstance(value, str): 190 | s = textwrap.dedent(value) 191 | base.join(key).write(s) 192 | 193 | 194 | @pytest.fixture 195 | def initproj(tmpdir): 196 | """Create a factory function for creating example projects. 197 | 198 | Constructed folder/file hierarchy examples: 199 | 200 | with `src_root` other than `.`: 201 | 202 | tmpdir/ 203 | name/ # base 204 | src_root/ # src_root 205 | name/ # package_dir 206 | __init__.py 207 | name.egg-info/ # created later on package build 208 | setup.py 209 | 210 | with `src_root` given as `.`: 211 | 212 | tmpdir/ 213 | name/ # base, src_root 214 | name/ # package_dir 215 | __init__.py 216 | name.egg-info/ # created later on package build 217 | setup.py 218 | """ 219 | 220 | def initproj_(nameversion, filedefs=None, src_root=".", kind="setup.py"): 221 | if filedefs is None: 222 | filedefs = {} 223 | if not src_root: 224 | src_root = "." 225 | if isinstance(nameversion, str): 226 | parts = nameversion.split("-") 227 | if len(parts) == 1: 228 | parts.append("0.1") 229 | name, version = parts 230 | else: 231 | name, version = nameversion 232 | base = tmpdir.join(name) 233 | src_root_path = base / src_root 234 | assert base == src_root_path or src_root_path.relto( 235 | base 236 | ), "`src_root` must be the constructed project folder or its direct or indirect subfolder" 237 | 238 | base.ensure(dir=1) 239 | create_files(base, filedefs) 240 | if not _filedefs_contains(base, filedefs, "setup.py") and kind == "setup.py": 241 | create_files( 242 | base, 243 | { 244 | "setup.py": """ 245 | from setuptools import setup, find_packages 246 | setup( 247 | name='{name}', 248 | description='{name} project', 249 | version='{version}', 250 | license='MIT', 251 | platforms=['unix', 'win32'], 252 | packages=find_packages('{src_root}'), 253 | package_dir={{'':'{src_root}'}}, 254 | ) 255 | """.format( 256 | **locals() 257 | ) 258 | }, 259 | ) 260 | if not _filedefs_contains(base, filedefs, "pyproject.toml") and kind == "setup.cfg": 261 | create_files(base, {"pyproject.toml": """ 262 | [build-system] 263 | requires = ["setuptools", "wheel"] 264 | """}) 265 | if not _filedefs_contains(base, filedefs, "setup.cfg") and kind == "setup.cfg": 266 | create_files(base, {"setup.cfg": """ 267 | [metadata] 268 | name = {name} 269 | description= {name} project 270 | version = {version} 271 | license = MIT 272 | packages = find: 273 | """.format(**locals())}) 274 | if not _filedefs_contains(base, filedefs, "pyproject.toml") and kind == "pyproject.toml": 275 | create_files(base, {"pyproject.toml": """ 276 | [build-system] 277 | requires = ["flit_core >=3.2"] 278 | build-backend = "flit_core.buildapi" 279 | 280 | [project] 281 | name = "{name}" 282 | description= "{name} project" 283 | version = "{version}" 284 | license = {{text="MIT"}} 285 | packages = "find:" 286 | """.format(**locals())}) 287 | if not _filedefs_contains(base, filedefs, src_root_path.join(name)): 288 | create_files( 289 | src_root_path, {name: {"__init__.py": "__version__ = {!r}".format(version)}} 290 | ) 291 | manifestlines = [ 292 | "include {}".format(p.relto(base)) for p in base.visit(lambda x: x.check(file=1)) 293 | ] 294 | create_files(base, {"MANIFEST.in": "\n".join(manifestlines)}) 295 | print("created project in {}".format(base)) 296 | base.chdir() 297 | return base 298 | 299 | return initproj_ 300 | 301 | 302 | def _check_output(request, args, env=None): 303 | result = subprocess.run( 304 | args, # noqa: S603 only used for tests 305 | check=False, 306 | env=env, 307 | stdout=subprocess.PIPE, stderr=subprocess.STDOUT) 308 | if not result.returncode: 309 | print(result.stdout.decode()) # noqa: T201 only used for tests 310 | else: 311 | capman = request.config.pluginmanager.getplugin("capturemanager") 312 | capman.suspend() 313 | print(result.stdout.decode()) # noqa: T201 only used for tests 314 | capman.resume() 315 | result.check_returncode() 316 | return result 317 | 318 | 319 | def check_call(request, args, env=None): 320 | _check_output(request, args, env=env) 321 | 322 | 323 | @pytest.fixture 324 | def create_venv(request, tmpdir_factory, monkeypatch): 325 | monkeypatch.delenv("PYTHONDONTWRITEBYTECODE", raising=False) 326 | venvdir = tmpdir_factory.mktemp("venv") 327 | venvinstalldir = tmpdir_factory.mktemp("inst") 328 | 329 | def do_create_venv(): 330 | # we need to change directory, otherwise the path will become 331 | # too long on windows 332 | venvinstalldir.ensure_dir() 333 | os.chdir(venvinstalldir) 334 | check_call(request, [ 335 | "virtualenv", "--never-download", str(venvdir)]) 336 | # activate 337 | if sys.platform == "win32": 338 | bindir = "Scripts" 339 | else: 340 | bindir = "bin" 341 | monkeypatch.setenv("PATH", bindir + os.pathsep + os.environ["PATH"]) 342 | return venvdir 343 | 344 | return do_create_venv 345 | -------------------------------------------------------------------------------- /tests/test_functional.py: -------------------------------------------------------------------------------- 1 | from devpi import __version__ as devpi_client_version 2 | from pkg_resources import parse_version 3 | import pytest 4 | 5 | 6 | devpi_client_version = parse_version(devpi_client_version) 7 | is_atleast_client6 = devpi_client_version >= parse_version("6dev") 8 | 9 | 10 | def test_index_when_unauthorized(capfd, devpi): 11 | devpi("logout") 12 | (out, err) = capfd.readouterr() 13 | if is_atleast_client6: 14 | devpi("index") 15 | (out, err) = capfd.readouterr() 16 | assert "you need to be logged" in out 17 | else: 18 | devpi("index", code=403) 19 | 20 | 21 | @pytest.mark.skipif( 22 | not is_atleast_client6, 23 | reason="Needs authentication passing to pip") 24 | def test_devpi_install(capfd, create_venv, devpi, initproj, monkeypatch): 25 | pkg = initproj("foo-1.0") 26 | with pkg.as_cwd(): 27 | devpi("upload", code=200) 28 | (out, err) = capfd.readouterr() 29 | assert "file_upload of foo" in out 30 | venvdir = create_venv() 31 | monkeypatch.setenv("VIRTUAL_ENV", venvdir.strpath) 32 | devpi("install", "foo") 33 | (out, err) = capfd.readouterr() 34 | assert "Successfully installed foo" in out 35 | 36 | 37 | @pytest.mark.skipif( 38 | not is_atleast_client6, 39 | reason="Needs authentication passing to pip") 40 | def test_devpi_test(capfd, create_venv, devpi, initproj, monkeypatch): 41 | foo = initproj( 42 | "foo-1.0", 43 | filedefs={ 44 | "tox.ini": """ 45 | [testenv] 46 | commands = python -c "print('ok')" 47 | deps = bar==1.0 48 | """}) 49 | with foo.as_cwd(): 50 | devpi("upload", code=200) 51 | bar = initproj("bar-1.0") 52 | with bar.as_cwd(): 53 | devpi("upload", code=200) 54 | (out, err) = capfd.readouterr() 55 | assert "file_upload of foo" in out 56 | venvdir = create_venv() 57 | monkeypatch.setenv("VIRTUAL_ENV", venvdir.strpath) 58 | devpi("test", "foo") 59 | (out, err) = capfd.readouterr() 60 | (line,) = [ 61 | x for x in out.splitlines() 62 | if 'install_deps' in x or 'installdeps' in x] 63 | assert "bar==1.0" in line 64 | assert "commands succeeded" in out or "congratulations :)" in out 65 | assert "success" in out 66 | -------------------------------------------------------------------------------- /tests/test_lockdown.py: -------------------------------------------------------------------------------- 1 | from devpi_server import __version__ as devpi_server_version 2 | from pkg_resources import parse_version 3 | from webob.headers import ResponseHeaders 4 | import pytest 5 | import subprocess 6 | 7 | 8 | devpi_server_version = parse_version(devpi_server_version) 9 | pytestmark = [pytest.mark.notransaction] 10 | 11 | 12 | def test_importable(): 13 | import devpi_lockdown 14 | assert devpi_lockdown.__version__ 15 | 16 | 17 | def test_login(mapp, testapp): 18 | mapp.create_user("user1", "1") 19 | testapp.xget(401, 'http://localhost/+authcheck') 20 | testapp.xget(200, 'http://localhost/+login') 21 | r = testapp.post( 22 | 'http://localhost/+login?goto_url=/foo/bar', 23 | dict(username="user1", password="1", submit="")) 24 | assert r.status_code == 302 25 | assert r.location == 'http://localhost/foo/bar' 26 | testapp.xget(200, 'http://localhost/+authcheck') 27 | 28 | 29 | def test_goto_url_with_plus(mapp, testapp): 30 | mapp.create_user("user1", "1") 31 | r = testapp.post( 32 | 'http://localhost/+login?goto_url=http://localhost/+status', 33 | dict(username="user1", password="1", submit="")) 34 | assert r.status_code == 302 35 | assert r.location == 'http://localhost/+status' 36 | 37 | 38 | def test_login_bad_goto_url(mapp, testapp): 39 | mapp.create_user("user1", "1") 40 | r = testapp.post( 41 | 'http://localhost/+login?goto_url=https://github.com', 42 | dict(username="user1", password="1", submit="")) 43 | assert r.status_code == 302 44 | assert r.location == 'http://localhost/' 45 | 46 | 47 | def test_login_differing_goto_url_scheme(mapp, testapp): 48 | mapp.create_user("user1", "1") 49 | r = testapp.post( 50 | 'http://localhost/+login?goto_url=https://localhost/foo', 51 | dict(username="user1", password="1", submit="")) 52 | assert r.status_code == 302 53 | assert r.location == 'http://localhost/' 54 | 55 | 56 | def test_login_invalid_credentials(mapp, testapp): 57 | mapp.create_user("user1", "1") 58 | r = testapp.post( 59 | 'http://localhost/+login', 60 | dict(username="user1", password="wrong", submit=""), 61 | code=401) 62 | assert 'Invalid credentials' in r.text 63 | testapp.xget(401, 'http://localhost/+authcheck') 64 | 65 | 66 | def test_always_ok(testapp): 67 | testapp.xget( 68 | 200, 'http://localhost/+authcheck', 69 | headers=ResponseHeaders({ 70 | 'X-Original-URI': 'http://localhost/+api'})) 71 | testapp.xget( 72 | 200, 'http://localhost/+authcheck', 73 | headers=ResponseHeaders({ 74 | 'X-Original-URI': 'http://localhost/+login', 75 | 'Accept': 'application/json'})) 76 | testapp.xget( 77 | 200, 'http://localhost/+authcheck', 78 | headers=ResponseHeaders({ 79 | 'X-Original-URI': 'http://localhost/+login'})) 80 | r = testapp.xget(200, 'http://localhost/+login') 81 | for elem in r.html.select('link, script'): 82 | uri = None 83 | if elem.name.lower() == 'link': 84 | uri = elem.attrs.get('href') 85 | elif elem.name.lower() == 'script': 86 | uri = elem.attrs.get('src') 87 | if not uri: 88 | continue 89 | testapp.xget( 90 | 200, 'http://localhost/+authcheck', 91 | headers=ResponseHeaders({ 92 | 'X-Original-URI': uri})) 93 | 94 | 95 | def test_get_current_request(maketestapp, makexom): 96 | from devpi_lockdown import main as lockdown_plugin 97 | from devpi_lockdown.main import devpiserver_hookimpl 98 | from pyramid.authentication import b64encode 99 | from pyramid.threadlocal import get_current_request 100 | from webob.headers import ResponseHeaders 101 | 102 | calls = [] 103 | 104 | class Plugin: 105 | @devpiserver_hookimpl 106 | def devpiserver_get_credentials(self, request): 107 | calls.append(request) 108 | current_request = get_current_request() 109 | assert request is current_request 110 | 111 | plugin = Plugin() 112 | xom = makexom(plugins=[lockdown_plugin, plugin]) 113 | testapp = maketestapp(xom) 114 | basic_auth = '%s:%s' % ('user1', '1') 115 | testapp.xget( 116 | 401, 'http://localhost/+authcheck', 117 | headers=ResponseHeaders({ 118 | 'Authorization': 'MyBasic %s' % b64encode(basic_auth).decode('ascii'), 119 | 'X-Original-URI': 'http://localhost/foo/bar/+simple/pkg'})) 120 | assert calls 121 | 122 | 123 | @pytest.mark.skipif( 124 | devpi_server_version < parse_version("6dev"), 125 | reason="Needs devpiserver_genconfig hook") 126 | def test_gen_config(tmpdir): 127 | import re 128 | 129 | tmpdir.chdir() 130 | proc = subprocess.Popen(["devpi-gen-config"]) 131 | res = proc.wait() 132 | assert res == 0 133 | path = tmpdir.join("gen-config").join("nginx-devpi-lockdown.conf") 134 | assert path.check() 135 | lines = path.read().splitlines() 136 | 137 | def find_line(content): 138 | regexp = re.compile(content, re.I) 139 | for index, line in enumerate(lines): 140 | if regexp.search(line): 141 | return (index, line) 142 | 143 | (server_index, server_line) = find_line("server_name") 144 | (auth_index, auth_line) = find_line("auth_request") 145 | (proxy_index, proxy_line) = find_line("location\\s+@proxy_to_app") 146 | assert "/+authcheck" in auth_line 147 | assert server_index < auth_index < proxy_index 148 | 149 | 150 | @pytest.mark.skipif( 151 | devpi_server_version < parse_version("6dev"), 152 | reason="Needs devpiserver_authcheck_* hooks") 153 | def test_forbidden_plugin(makemapp, maketestapp, makexom): 154 | from devpi_lockdown.main import devpiserver_hookimpl 155 | from devpi_server.model import ACLList 156 | from webob.headers import ResponseHeaders 157 | 158 | class Plugin: 159 | @devpiserver_hookimpl 160 | def devpiserver_indexconfig_defaults(self, index_type): 161 | return {"acl_pkg_read": ACLList([':ANONYMOUS:'])} 162 | 163 | @devpiserver_hookimpl 164 | def devpiserver_stage_get_principals_for_pkg_read(self, ixconfig): 165 | return ixconfig.get('acl_pkg_read', None) 166 | 167 | @devpiserver_hookimpl 168 | def devpiserver_authcheck_forbidden(self, request): 169 | if request.authenticated_userid: 170 | stage = request.context._stage 171 | if stage and not request.has_permission('pkg_read'): 172 | return True 173 | 174 | plugin = Plugin() 175 | xom = makexom(plugins=[plugin]) 176 | testapp = maketestapp(xom) 177 | mapp = makemapp(testapp) 178 | api1 = mapp.create_and_use("someuser/dev", indexconfig=dict( 179 | acl_pkg_read="someuser")) 180 | mapp.upload_file_pypi("hello-1.0.tar.gz", b'content', "hello", "1.0") 181 | (path,) = mapp.get_release_paths("hello") 182 | # current user should be able to read package and index 183 | testapp.xget(200, api1.index) 184 | testapp.xget( 185 | 200, '/+authcheck', 186 | headers=ResponseHeaders({'X-Original-URI': api1.index})) 187 | testapp.xget(200, path) 188 | testapp.xget( 189 | 200, '/+authcheck', 190 | headers=ResponseHeaders({'X-Original-URI': 'http://localhost' + path})) 191 | # create another user 192 | mapp.create_and_use("otheruser/dev") 193 | # the user should be able to access the index 194 | testapp.xget(200, api1.index) 195 | # but the authcheck will fail, so through nginx it will be blocked 196 | testapp.xget( 197 | 403, '/+authcheck', 198 | headers=ResponseHeaders({'X-Original-URI': api1.index})) 199 | # the package should be forbidden 200 | testapp.xget(403, path) 201 | testapp.xget( 202 | 403, '/+authcheck', 203 | headers=ResponseHeaders({'X-Original-URI': 'http://localhost' + path})) 204 | 205 | 206 | @pytest.mark.skipif( 207 | devpi_server_version < parse_version("6dev"), 208 | reason="Needs devpiserver_authcheck_* hooks") 209 | def test_inherited_forbidden_plugin(makemapp, maketestapp, makexom): 210 | from devpi_lockdown.main import devpiserver_hookimpl 211 | from devpi_server.model import ACLList 212 | from webob.headers import ResponseHeaders 213 | 214 | class Plugin: 215 | @devpiserver_hookimpl 216 | def devpiserver_indexconfig_defaults(self, index_type): 217 | return {"acl_pkg_read": ACLList([':ANONYMOUS:'])} 218 | 219 | @devpiserver_hookimpl 220 | def devpiserver_stage_get_principals_for_pkg_read(self, ixconfig): 221 | return ixconfig.get('acl_pkg_read', None) 222 | 223 | @devpiserver_hookimpl 224 | def devpiserver_authcheck_forbidden(self, request): 225 | if not request.authenticated_userid: 226 | return 227 | stage = request.context._stage 228 | if not stage: 229 | return 230 | for _stage in stage.sro(): 231 | if not request.has_permission('pkg_read', _stage): 232 | return True 233 | 234 | plugin = Plugin() 235 | xom = makexom(plugins=[plugin]) 236 | testapp = maketestapp(xom) 237 | mapp = makemapp(testapp) 238 | api1 = mapp.create_and_use("someuser/dev", indexconfig=dict( 239 | acl_pkg_read="someuser")) 240 | mapp.upload_file_pypi("hello-1.0.tar.gz", b'content', "hello", "1.0") 241 | (path,) = mapp.get_release_paths("hello") 242 | # current user should be able to read package and index 243 | testapp.xget(200, api1.index) 244 | testapp.xget( 245 | 200, '/+authcheck', 246 | headers=ResponseHeaders({'X-Original-URI': api1.index})) 247 | testapp.xget(200, path) 248 | testapp.xget( 249 | 200, '/+authcheck', 250 | headers=ResponseHeaders({'X-Original-URI': 'http://localhost' + path})) 251 | # create another user and index deriving from the previous 252 | api2 = mapp.create_and_use("otheruser/dev", indexconfig=dict(bases="someuser/dev")) 253 | # the user should be able to access the first index 254 | testapp.xget(200, api1.index) 255 | # but the authcheck will fail, so through nginx it will be blocked 256 | testapp.xget( 257 | 403, '/+authcheck', 258 | headers=ResponseHeaders({'X-Original-URI': api1.index})) 259 | # the package should be forbidden 260 | testapp.xget(403, path) 261 | testapp.xget( 262 | 403, '/+authcheck', 263 | headers=ResponseHeaders({'X-Original-URI': 'http://localhost' + path})) 264 | # the users own index should be accessible 265 | testapp.xget(200, api2.index) 266 | # but the authcheck will fail due to inheritance, so through nginx it will be blocked 267 | testapp.xget( 268 | 403, '/+authcheck', 269 | headers=ResponseHeaders({'X-Original-URI': api2.index})) 270 | 271 | 272 | @pytest.mark.skipif( 273 | devpi_server_version < parse_version("6dev"), 274 | reason="Needs devpiserver_auth_denials hook") 275 | def test_deny_login(makemapp, maketestapp, makexom): 276 | from devpi_lockdown import main as lockdown_plugin 277 | from devpi_lockdown.main import devpiserver_hookimpl 278 | from devpi_web import main as web_plugin 279 | 280 | class Plugin: 281 | @devpiserver_hookimpl 282 | def devpiserver_auth_denials(self, request, acl, user, stage): 283 | return self.results.pop() 284 | 285 | plugin = Plugin() 286 | xom = makexom(plugins=[lockdown_plugin, plugin, web_plugin]) 287 | testapp = maketestapp(xom) 288 | mapp = makemapp(testapp) 289 | plugin.results = [None] 290 | mapp.create_user("user1", "1") 291 | plugin.results = [[('user1', 'user_login')]] 292 | r = testapp.post( 293 | 'http://localhost/+login?goto_url=/foo/bar', 294 | dict(username="user1", password="1", submit=""), 295 | code=401) 296 | assert "has no permission to login with the" in r.text 297 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{39,313} 3 | 4 | 5 | [testenv] 6 | commands = py.test --cov {envsitepackagesdir}/devpi_lockdown {posargs:tests} 7 | deps = 8 | webtest 9 | mock 10 | pytest 11 | pytest-cov 12 | devpi-client 13 | 14 | 15 | [pytest] 16 | addopts = --cov-report=term --cov-report=html 17 | testpaths = devpi_lockdown tests 18 | --------------------------------------------------------------------------------