diff --git a/passerelle/apps/proxy/__init__.py b/passerelle/apps/proxy/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/passerelle/apps/proxy/migrations/0001_initial.py b/passerelle/apps/proxy/migrations/0001_initial.py new file mode 100644 index 00000000..15169a63 --- /dev/null +++ b/passerelle/apps/proxy/migrations/0001_initial.py @@ -0,0 +1,82 @@ +# Generated by Django 2.2.26 on 2023-01-08 12:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('base', '0030_resourcelog_base_resour_appname_298cbc_idx'), + ] + + operations = [ + migrations.CreateModel( + name='Resource', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ('title', models.CharField(max_length=50, verbose_name='Title')), + ('slug', models.SlugField(unique=True, verbose_name='Identifier')), + ('description', models.TextField(verbose_name='Description')), + ( + 'basic_auth_username', + models.CharField( + blank=True, max_length=128, verbose_name='Basic authentication username' + ), + ), + ( + 'basic_auth_password', + models.CharField( + blank=True, max_length=128, verbose_name='Basic authentication password' + ), + ), + ( + 'client_certificate', + models.FileField( + blank=True, null=True, upload_to='', verbose_name='TLS client certificate' + ), + ), + ( + 'trusted_certificate_authorities', + models.FileField(blank=True, null=True, upload_to='', verbose_name='TLS trusted CAs'), + ), + ( + 'verify_cert', + models.BooleanField(blank=True, default=True, verbose_name='TLS verify certificates'), + ), + ( + 'http_proxy', + models.CharField(blank=True, max_length=128, verbose_name='HTTP and HTTPS proxy'), + ), + ('upstream_base_url', models.URLField(verbose_name='Upstream Service Base URL')), + ( + 'http_timeout', + models.PositiveIntegerField(default=20, verbose_name='Timeout on upstream (in seconds)'), + ), + ( + 'forced_headers', + models.TextField( + blank=True, + help_text='Headers to always add (one per line, format "Header-Name: value")', + verbose_name='Headers', + ), + ), + ( + 'users', + models.ManyToManyField( + blank=True, + related_name='_resource_users_+', + related_query_name='+', + to='base.ApiUser', + ), + ), + ], + options={ + 'verbose_name': 'Proxy', + }, + ), + ] diff --git a/passerelle/apps/proxy/migrations/__init__.py b/passerelle/apps/proxy/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/passerelle/apps/proxy/models.py b/passerelle/apps/proxy/models.py new file mode 100644 index 00000000..08786f89 --- /dev/null +++ b/passerelle/apps/proxy/models.py @@ -0,0 +1,94 @@ +# passerelle - uniform access to multiple data sources and services +# Copyright (C) 2023 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from urllib.parse import parse_qsl + +from django.db import models +from django.http.response import HttpResponse +from django.utils.translation import gettext_lazy as _ + +from passerelle.base.models import BaseResource, HTTPResource +from passerelle.utils.api import endpoint + +PASS_HEADERS_REQUEST = ( + 'accept', + 'accept-encoding', + 'accept-language', + 'cookie', + 'user-agent', +) + + +class Resource(BaseResource, HTTPResource): + category = _('Misc') + + upstream_base_url = models.URLField(_('Upstream Service Base URL')) + http_timeout = models.PositiveIntegerField(_('Timeout on upstream (in seconds)'), default=20) + forced_headers = models.TextField( + _('Headers'), + blank=True, + help_text=_('Headers to always add (one per line, format "Header-Name: value")'), + ) + + class Meta: + verbose_name = _('Proxy') + + @endpoint( + name='request', + perm='can_access', + methods=['get', 'post', 'delete', 'put', 'patch'], + pattern=r'^(?P.*)$', + description=_('Make a request'), + example_pattern='{path}', + parameters={ + 'path': { + 'description': _('request will be made on Upstream Service Base URL + path'), + 'example_value': 'foo/bar', + } + }, + ) + def request(self, request, path, *args, **kwargs): + params = parse_qsl(request.META.get('QUERY_STRING')) + if params and params[-1][0] == 'signature': + # remove Publik signature parts: orig, algo, timestamp, nonce, signature + params = [ + (k, v) for k, v in params if k not in ('orig', 'algo', 'timestamp', 'nonce', 'signature') + ] + headers = {k: v for k, v in request.headers.items() if v and k.lower() in PASS_HEADERS_REQUEST} + if request.method != 'GET': + headers['Content-Type'] = request.headers.get('content-type') + for header in self.forced_headers.splitlines(): + header = header.strip() + if header.startswith('#'): + continue + header = header.split(':', 1) + if len(header) == 2: + headers[header[0].strip()] = header[1].strip() + upstream = self.requests.request( + method=request.method, + url=self.upstream_base_url + path, + headers=headers, + params=params, + data=request.body, + timeout=self.http_timeout, + ) + response = HttpResponse( + upstream.content, + content_type=upstream.headers.get('Content-Type'), + status=upstream.status_code, + reason=upstream.reason, + ) + return response diff --git a/passerelle/settings.py b/passerelle/settings.py index 3626bbd6..464c3b0c 100644 --- a/passerelle/settings.py +++ b/passerelle/settings.py @@ -168,6 +168,7 @@ INSTALLED_APPS = ( 'passerelle.apps.phonecalls', 'passerelle.apps.photon', 'passerelle.apps.plone_restapi', + 'passerelle.apps.proxy', 'passerelle.apps.sector', 'passerelle.apps.sfr_dmc', 'passerelle.apps.signal_arretes', diff --git a/tests/test_proxy.py b/tests/test_proxy.py new file mode 100644 index 00000000..d4ab3df5 --- /dev/null +++ b/tests/test_proxy.py @@ -0,0 +1,169 @@ +# passerelle - uniform access to multiple data sources and services +# Copyright (C) 2023 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import pytest +import responses +import responses.matchers + +import tests.utils +from passerelle.apps.proxy.models import Resource + + +@pytest.fixture +def proxy(db): + return tests.utils.setup_access_rights( + Resource.objects.create(slug='echo', upstream_base_url='https://example.org/') + ) + + +@pytest.fixture +def endpoint(proxy): + return tests.utils.generic_endpoint_url('proxy', 'request', slug=proxy.slug) + + +@pytest.fixture +def mocked_responses(): + with responses.RequestsMock(assert_all_requests_are_fired=False) as rsp: + yield rsp + + +def test_get(mocked_responses, app, proxy, endpoint): + mocked_responses.get( + url='https://example.org/foo/bar', + body='ok', + match=[ + responses.matchers.query_string_matcher(''), + ], + ) + + resp = app.get(endpoint + '/foo/bar', status=200) + assert resp.text == 'ok' + + +def test_status(mocked_responses, app, proxy, endpoint): + mocked_responses.get(url='https://example.org/foo/bar', body='bad request', status=400) + resp = app.get(endpoint + '/foo/bar', status=400) + assert resp.text == 'bad request' + + +def test_post(mocked_responses, app, proxy, endpoint): + mocked_responses.post( + url='https://example.org/post', + body='ok', + match=[ + responses.matchers.json_params_matcher({'foo': 'bar'}), + ], + ) + resp = app.post_json(endpoint + '/post', params={'foo': 'bar'}, status=200) + assert resp.text == 'ok' + + +def no_header_matcher(header_name): + def inner(request): + if header_name in request.headers: + return False, f'Found header "{header_name} in requests headers.' + return True, None + + return inner + + +def test_headers(mocked_responses, app, proxy, endpoint): + mocked_responses.get( + url='https://example.org/foo/bar', + body='ok', + match=[ + responses.matchers.header_matcher({'User-Agent': 'test-ua'}), + no_header_matcher('x-foo'), + no_header_matcher('dontpass'), + ], + ) + resp = app.get(endpoint + '/foo/bar', headers={'user-agent': 'test-ua', 'dontpass': 'x'}, status=200) + assert resp.text == 'ok' + + +def test_forced_headers(mocked_responses, app, proxy, endpoint): + proxy.forced_headers = ''' +x-foo : bar +badentry + +# comment: do not use me + ''' + proxy.save() + + mocked_responses.get( + url='https://example.org/foo/bar', + body='ok', + match=[ + responses.matchers.header_matcher({'x-foo': 'bar'}), + no_header_matcher('dontpass'), + ], + ) + resp = app.get(endpoint + '/foo/bar', headers={'user-agent': 'test', 'dontpass': 'x'}, status=200) + assert resp.text == 'ok' + + +def test_query_parameters(mocked_responses, app, proxy, endpoint): + mocked_responses.get( + url='https://example.org/foo/bar', + body='ok', + match=[ + responses.matchers.query_param_matcher({'param1': '1', 'param2': '2'}, strict_match=True), + ], + ) + + resp = app.get(endpoint + '/foo/bar?param1=1¶m2=2', status=200) + assert resp.text == 'ok' + + +def test_publik_signature_is_removed(mocked_responses, app, proxy, endpoint): + mocked_responses.get( + url='https://example.org/foo/bar', + body='ok', + match=[ + responses.matchers.query_param_matcher({'param1': '1', 'param2': '2'}, strict_match=True), + ], + ) + resp = app.get( + endpoint + '/foo/bar?param1=1¶m2=2&orig=coucou&algo=foo&nonce=bar×tamp=xxx&signature=okok', + status=200, + ) + assert resp.text == 'ok' + + +def test_auth(mocked_responses, app, proxy, endpoint): + proxy.basic_auth_username = 'test-login' + proxy.basic_auth_password = 'test-pass' + proxy.save() + + mocked_responses.get( + url='https://example.org/foo/bar', + body='ok', + match=[responses.matchers.header_matcher({'Authorization': 'Basic dGVzdC1sb2dpbjp0ZXN0LXBhc3M='})], + ) + resp = app.get(endpoint + '/foo/bar', status=200) + assert resp.text == 'ok' + + +def test_timeout(mocked_responses, app, proxy, endpoint): + proxy.http_timeout = 5 + proxy.save() + mocked_responses.get( + url='https://example.org/foo/bar', + body='ok', + match=[responses.matchers.request_kwargs_matcher({'timeout': 5})], + ) + resp = app.get(endpoint + '/foo/bar', status=200) + assert resp.text == 'ok'