├── .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 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/devpi_lockdown/templates/logout.pt:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | devpi - Logout
5 |
6 |
7 |
8 |
9 |
10 |
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 |
--------------------------------------------------------------------------------