add franceconnect connector (#53879)
* follow the OAuth2 danse to get FranceConnect identite_pivot * with ?mode=dgfip, also request an access_token to call DGFIP IR web-service * call the IR web-service with two access tokens : * one from DGFIP * one from FC
This commit is contained in:
parent
8f697bdab3
commit
00e985b845
|
@ -0,0 +1,196 @@
|
|||
# passerelle - uniform access to multiple data sources and services
|
||||
# Copyright (C) 2021 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
import base64
|
||||
import json
|
||||
import urllib.parse
|
||||
import uuid
|
||||
|
||||
import requests
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
|
||||
class FranceConnectError(Exception):
|
||||
def __init__(self, message, **kwargs):
|
||||
self.data = tuple(kwargs.items())
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class Test:
|
||||
slug = 'test'
|
||||
name = _('Testing')
|
||||
authorize_url = 'https://fcp.integ01.dev-franceconnect.fr/api/v1/authorize'
|
||||
token_endpoint_url = 'https://fcp.integ01.dev-franceconnect.fr/api/v1/token'
|
||||
user_info_endpoint_url = 'https://fcp.integ01.dev-franceconnect.fr/api/v1/userinfo'
|
||||
logout_url = 'https://fcp.integ01.dev-franceconnect.fr/api/v1/logout'
|
||||
|
||||
|
||||
class Prod:
|
||||
slug = 'prod'
|
||||
name = _('Production')
|
||||
authorize_url = 'https://app.franceconnect.gouv.fr/api/v1/authorize'
|
||||
token_endpoint_url = 'https://app.franceconnect.gouv.fr/api/v1/token'
|
||||
user_info_endpoint_url = 'https://app.franceconnect.gouv.fr/api/v1/userinfo'
|
||||
logout_url = 'https://app.franceconnect.gouv.fr/api/v1/logout'
|
||||
|
||||
|
||||
PLATFORMS = [Test, Prod]
|
||||
PLATFORMS_BY_SLUG = {platform.slug: platform for platform in PLATFORMS}
|
||||
|
||||
|
||||
def base64url_decode(input):
|
||||
rem = len(input) % 4
|
||||
if rem > 0:
|
||||
input += b'=' * (4 - rem)
|
||||
return base64.urlsafe_b64decode(input)
|
||||
|
||||
|
||||
class FranceConnect:
|
||||
def __init__(self, session, logger):
|
||||
self.session = session
|
||||
self.logger = logger
|
||||
self.items = []
|
||||
self.correlation_id = str(uuid.uuid4())
|
||||
|
||||
def authorization_request(self, platform, client_id, scopes, redirect_uri, acr_values='eidas1'):
|
||||
'''Launch an authorization request to FranceConnect'''
|
||||
qs = urllib.parse.urlencode(
|
||||
{
|
||||
'response_type': 'code',
|
||||
'client_id': client_id,
|
||||
'redirect_uri': redirect_uri,
|
||||
'scope': 'openid ' + scopes,
|
||||
'state': str(uuid.uuid4()),
|
||||
'nonce': str(uuid.uuid4()),
|
||||
'acr_values': acr_values,
|
||||
}
|
||||
)
|
||||
return '%s?%s' % (platform.authorize_url, qs)
|
||||
|
||||
def handle_authorization_response(
|
||||
self, platform, client_id, client_secret, redirect_uri, code, error, error_description
|
||||
):
|
||||
if error:
|
||||
raise FranceConnectError(
|
||||
'No authorization code', error=error, error_description=error_description
|
||||
)
|
||||
|
||||
data = {
|
||||
'grant_type': 'authorization_code',
|
||||
'redirect_uri': redirect_uri,
|
||||
'client_id': client_id,
|
||||
'client_secret': client_secret,
|
||||
'code': code,
|
||||
}
|
||||
|
||||
response_content = self.request('token endpoint', 'POST', platform.token_endpoint_url, data=data)
|
||||
|
||||
try:
|
||||
self.add('fc_token_endpoint_response', response_content)
|
||||
self.add('fc_access_token', response_content['access_token'])
|
||||
self.add('fc_id_token', response_content['id_token'])
|
||||
header, payload, signature = self.fc_id_token.split('.')
|
||||
self.add('fc_id_token_payload', json.loads(base64url_decode(payload.encode())))
|
||||
except Exception as e:
|
||||
raise FranceConnectError('Error in token endpoint response', sub_exception=repr(e))
|
||||
|
||||
fc_user_info = self.request(
|
||||
'user_info endpoint',
|
||||
'GET',
|
||||
platform.user_info_endpoint_url,
|
||||
headers={'Authorization': 'Bearer %s' % self.fc_access_token},
|
||||
)
|
||||
self.add('fc_user_info', fc_user_info)
|
||||
|
||||
def request_dgfip_access_token(self, dgfip_username, dgfip_password, scope=None):
|
||||
data = {
|
||||
'grant_type': 'client_credentials',
|
||||
}
|
||||
if scope:
|
||||
data['scope'] = scope
|
||||
dgfip_response = self.request(
|
||||
'dgfip token endpoint',
|
||||
'POST',
|
||||
'https://gwfc.impots.gouv.fr/token',
|
||||
data=data,
|
||||
auth=(dgfip_username, dgfip_password),
|
||||
)
|
||||
|
||||
self.add('dgfip_token_endpoint_response', dgfip_response)
|
||||
|
||||
try:
|
||||
dgfip_access_token = dgfip_response['access_token']
|
||||
except (TypeError, KeyError) as e:
|
||||
raise FranceConnectError('dgfip token endpoint error %s' % e, response=dgfip_response)
|
||||
self.add('dgfip_access_token', dgfip_access_token)
|
||||
|
||||
def request_dgfip_ir(self, annrev, id_teleservice=None):
|
||||
headers = {
|
||||
'Authorization': 'Bearer %s' % self.dgfip_access_token,
|
||||
'X-FranceConnect-OAuth': self.fc_access_token,
|
||||
'X-Correlation-ID': str(uuid.uuid4()),
|
||||
'Accept': 'application/prs.dgfip.part.situations.ir.assiettes.v1+json',
|
||||
}
|
||||
if id_teleservice:
|
||||
headers['ID_Teleservice'] = id_teleservice
|
||||
|
||||
try:
|
||||
dgfip_ressource_ir_response = self.request(
|
||||
'ressource IR endpoint',
|
||||
'GET',
|
||||
'https://gwfc.impots.gouv.fr/impotparticulier/1.0/situations/ir/assiettes/annrev/%s' % annrev,
|
||||
headers=headers,
|
||||
)
|
||||
except FranceConnectError as e:
|
||||
dgfip_ressource_ir_response = {'error_desc': str(e), 'error': e.data}
|
||||
|
||||
# accumulate data
|
||||
try:
|
||||
data = self.dgfip_ressource_ir_response
|
||||
except AttributeError:
|
||||
data = {}
|
||||
data[annrev] = dgfip_ressource_ir_response
|
||||
self.add('dgfip_ressource_ir_response', data)
|
||||
|
||||
def __getattr__(self, name):
|
||||
try:
|
||||
return dict(self.items)[name]
|
||||
except KeyError:
|
||||
raise AttributeError(name)
|
||||
|
||||
def add(self, key, value):
|
||||
self.items.append((key, value))
|
||||
|
||||
def request(self, label, method, url, *args, **kwargs):
|
||||
self.logger.debug('request %s %s args:%s kwargs:%s', label, method, args, kwargs)
|
||||
self.add(label.replace(' ', '_') + '_request', [method, url, args, kwargs])
|
||||
try:
|
||||
response = getattr(self.session, method.lower())(url, *args, **kwargs)
|
||||
try:
|
||||
response_content = response.json()
|
||||
except ValueError:
|
||||
response_content = response.text[:1024]
|
||||
response.raise_for_status()
|
||||
raise
|
||||
else:
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as e:
|
||||
raise FranceConnectError('%s error %s' % (label, e), response=response_content)
|
||||
except requests.RequestException as e:
|
||||
raise FranceConnectError('%s error %s' % (label, e))
|
||||
except ValueError as e:
|
||||
raise FranceConnectError('%s error %s' % (label, e), response=response_content)
|
||||
return response_content
|
|
@ -0,0 +1,83 @@
|
|||
# Generated by Django 2.2.19 on 2021-05-17 11:43
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('base', '0029_auto_20210202_1627'),
|
||||
]
|
||||
|
||||
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')),
|
||||
(
|
||||
'fc_platform_slug',
|
||||
models.CharField(
|
||||
choices=[('test', 'Testing'), ('prod', 'Production')],
|
||||
max_length=4,
|
||||
verbose_name='FranceConnect platform',
|
||||
),
|
||||
),
|
||||
('fc_client_id', models.CharField(max_length=64, verbose_name='FranceConnect client_id')),
|
||||
(
|
||||
'fc_client_secret',
|
||||
models.CharField(max_length=64, verbose_name='FranceConnect client_secret'),
|
||||
),
|
||||
(
|
||||
'fc_scopes',
|
||||
models.TextField(default='identite_pivot', verbose_name='FranceConnect scopes'),
|
||||
),
|
||||
(
|
||||
'text_template',
|
||||
models.TextField(
|
||||
default="{{ given_name }} {{ family_name }} {% if gender == 'male' %}né{% else %}née{% endif %} le {{ birthdate }} à {{ birthplace }}",
|
||||
verbose_name='FranceConnect text template',
|
||||
),
|
||||
),
|
||||
(
|
||||
'dgfip_username',
|
||||
models.CharField(
|
||||
blank=True, max_length=64, null=True, verbose_name='api.impots.gouv.fr username'
|
||||
),
|
||||
),
|
||||
(
|
||||
'dgfip_password',
|
||||
models.CharField(
|
||||
blank=True, max_length=64, null=True, verbose_name='api.impots.gouv.fr password'
|
||||
),
|
||||
),
|
||||
(
|
||||
'dgfip_scopes',
|
||||
models.TextField(blank=True, null=True, verbose_name='api.impots.gouv.fr scopes'),
|
||||
),
|
||||
(
|
||||
'dgfip_id_teleservice',
|
||||
models.TextField(blank=True, null=True, verbose_name='api.impots.gouv.fr ID_Teleservice'),
|
||||
),
|
||||
(
|
||||
'users',
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name='_resource_users_+',
|
||||
related_query_name='+',
|
||||
to='base.ApiUser',
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'FranceConnect',
|
||||
},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,264 @@
|
|||
# passerelle - uniform access to multiple data sources and services
|
||||
# Copyright (C) 2021 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import models
|
||||
from django.http import HttpResponseBadRequest, HttpResponseRedirect
|
||||
from django.template import Context, Template
|
||||
from django.template.response import TemplateResponse
|
||||
from django.urls import reverse
|
||||
from django.utils.http import urlencode
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from passerelle.base.models import BaseResource
|
||||
from passerelle.utils import get_trusted_services
|
||||
from passerelle.utils.api import endpoint
|
||||
from passerelle.utils.origin import get_url_origin, is_same_origin
|
||||
|
||||
from . import fc
|
||||
|
||||
# from passerelle.utils.jsonresponse import APIError
|
||||
|
||||
|
||||
class Resource(BaseResource):
|
||||
category = _('Data Sources')
|
||||
|
||||
fc_platform_slug = models.CharField(
|
||||
_('FranceConnect platform'),
|
||||
max_length=4,
|
||||
choices=[(platform.slug, platform.name) for platform in fc.PLATFORMS],
|
||||
)
|
||||
|
||||
fc_client_id = models.CharField(_('FranceConnect client_id'), max_length=64)
|
||||
|
||||
fc_client_secret = models.CharField(_('FranceConnect client_secret'), max_length=64)
|
||||
|
||||
fc_scopes = models.TextField(_('FranceConnect scopes'), default='identite_pivot')
|
||||
|
||||
text_template = models.TextField(
|
||||
_('Text template'),
|
||||
default=(
|
||||
'''{{ fc.given_name }} {{ fc.family_name }} '''
|
||||
'''{% if fc.gender == 'male' %}né{% else %}née{% endif %} le {{ fc.birthdate }}'''
|
||||
),
|
||||
)
|
||||
|
||||
dgfip_username = models.CharField(_('api.impots.gouv.fr username'), max_length=64, blank=True, null=True)
|
||||
|
||||
dgfip_password = models.CharField(_('api.impots.gouv.fr password'), max_length=64, blank=True, null=True)
|
||||
|
||||
dgfip_scopes = models.TextField(_('api.impots.gouv.fr scopes'), blank=True, null=True)
|
||||
|
||||
dgfip_id_teleservice = models.TextField(_('api.impots.gouv.fr ID_Teleservice'), blank=True, null=True)
|
||||
|
||||
log_requests_errors = False
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Data sources through FranceConnect')
|
||||
|
||||
@property
|
||||
def fc_platform(self):
|
||||
return fc.PLATFORMS_BY_SLUG[self.fc_platform_slug]
|
||||
|
||||
def build_callback_url(self, request, **kwargs):
|
||||
redirect_uri = request.build_absolute_uri(
|
||||
reverse(
|
||||
'generic-endpoint',
|
||||
kwargs={'slug': self.slug, 'connector': self.get_connector_slug(), 'endpoint': 'callback'},
|
||||
)
|
||||
)
|
||||
if kwargs:
|
||||
redirect_uri += '?' + urlencode(
|
||||
{key: value for key, value in kwargs.items() if value is not None}
|
||||
)
|
||||
return redirect_uri
|
||||
|
||||
def is_trusted_origin(self, request, origin):
|
||||
for service in get_trusted_services():
|
||||
if is_same_origin(origin, service['url']):
|
||||
return True
|
||||
|
||||
if is_same_origin(request.build_absolute_uri(), origin):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@endpoint(
|
||||
description=_('Init request'),
|
||||
parameters={
|
||||
'mode': {
|
||||
'description': _('What to retrieve, default to FranceConnect identity, can be "dgfip"'),
|
||||
},
|
||||
'origin': {
|
||||
'description': _('Origin for returning results through window.postMessage'),
|
||||
},
|
||||
'test': {
|
||||
'description': _('If set to one, activate the test callback view.'),
|
||||
},
|
||||
},
|
||||
)
|
||||
def init_request(self, request, origin, mode=None, test=None):
|
||||
if not request.user.is_superuser and not self.is_trusted_origin(request, origin):
|
||||
return HttpResponseBadRequest('Missing or invalid origin')
|
||||
|
||||
redirect_uri = self.build_callback_url(request, origin=origin, mode=mode, test=test)
|
||||
franceconnect = fc.FranceConnect(session=self.requests, logger=self.logger)
|
||||
return HttpResponseRedirect(
|
||||
franceconnect.authorization_request(
|
||||
platform=self.fc_platform,
|
||||
client_id=self.fc_client_id,
|
||||
scopes=self.fc_scopes,
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
)
|
||||
|
||||
@endpoint(
|
||||
description=_('FranceConnect callback (internal use)'),
|
||||
parameters={
|
||||
'origin': {
|
||||
'description': _('HTTP Origin, needed to secure window.postMessage'),
|
||||
},
|
||||
'mode': {
|
||||
'description': _('Mode'),
|
||||
},
|
||||
'test': {
|
||||
'description': _('Use test mode (to see exchanges)'),
|
||||
},
|
||||
},
|
||||
)
|
||||
def callback(self, request, origin, mode=None, test=None, **kwargs):
|
||||
if not request.user.is_superuser and not self.is_trusted_origin(request, origin):
|
||||
return HttpResponseBadRequest('Missing or invalid origin.')
|
||||
|
||||
if test and not request.user.is_superuser:
|
||||
return HttpResponseBadRequest('Only admin can use test mode.')
|
||||
|
||||
franceconnect = fc.FranceConnect(session=self.requests, logger=self.logger)
|
||||
redirect_uri = self.build_callback_url(request, origin=origin, mode=mode, test=test)
|
||||
context = {
|
||||
'origin': origin,
|
||||
'franceconnect': franceconnect,
|
||||
'redirect_uri': redirect_uri,
|
||||
'test': test,
|
||||
}
|
||||
try:
|
||||
franceconnect.handle_authorization_response(
|
||||
platform=self.fc_platform,
|
||||
client_id=self.fc_client_id,
|
||||
client_secret=self.fc_client_secret,
|
||||
redirect_uri=redirect_uri,
|
||||
code=request.GET.get('code'),
|
||||
error=request.GET.get('error'),
|
||||
error_description=request.GET.get('error_description'),
|
||||
)
|
||||
token = {'franceconnect': franceconnect.fc_user_info}
|
||||
if mode == 'dgfip':
|
||||
franceconnect.request_dgfip_access_token(
|
||||
self.dgfip_username, self.dgfip_password, scope=self.dgfip_scopes
|
||||
)
|
||||
current_year = now().year
|
||||
for year in range(current_year - 3, current_year):
|
||||
franceconnect.request_dgfip_ir(str(year), id_teleservice=self.dgfip_id_teleservice)
|
||||
token['dgfip_ir'] = franceconnect.dgfip_ressource_ir_response
|
||||
try:
|
||||
template = Template(self.text_template)
|
||||
text_template_context = {
|
||||
'fc': franceconnect.fc_user_info.copy(),
|
||||
'dgfip': token.get('dgfip_ir'),
|
||||
}
|
||||
if franceconnect.fc_user_info.get('birthdate'):
|
||||
birthdate = franceconnect.fc_user_info['birthdate']
|
||||
try:
|
||||
text_template_context['fc']['birthdate'] = datetime.datetime.strptime(
|
||||
birthdate, '%Y-%m-%d'
|
||||
).date()
|
||||
except ValueError:
|
||||
pass
|
||||
token['text'] = template.render(Context(text_template_context))
|
||||
except Exception:
|
||||
token['text'] = '<failed to render>'
|
||||
context['data'] = {'id': self.store(token), 'text': token['text']}
|
||||
context['data_json'] = json.dumps({'id': self.store(token), 'text': token['text']})
|
||||
except fc.FranceConnectError as e:
|
||||
self.logger.warning('callback: error %s', e)
|
||||
context['error'] = e
|
||||
context['error_json'] = json.dumps(repr(e))
|
||||
context['error_data_json'] = json.dumps(e.data)
|
||||
return TemplateResponse(request, 'franceconnect_data/callback.html', context=context)
|
||||
|
||||
@endpoint(
|
||||
description=_('Demo page (to check your configuration)'),
|
||||
)
|
||||
def demo(self, request, **kwargs):
|
||||
if not request.user.is_superuser:
|
||||
return PermissionDenied
|
||||
return TemplateResponse(
|
||||
request,
|
||||
'franceconnect_data/demo.html',
|
||||
context={'origin': request.build_absolute_uri('/'), 'resource': self},
|
||||
)
|
||||
|
||||
@endpoint(
|
||||
description=_('Data source'),
|
||||
)
|
||||
def data_source(self, request, id=None, test=None, mode=None, **kwargs):
|
||||
if id:
|
||||
return {
|
||||
'data': [
|
||||
dict(self.retrieve(id), id=id),
|
||||
]
|
||||
}
|
||||
url = request.build_absolute_uri(
|
||||
reverse(
|
||||
'generic-endpoint',
|
||||
kwargs={
|
||||
'slug': self.slug,
|
||||
'connector': self.get_connector_slug(),
|
||||
'endpoint': 'init_request',
|
||||
},
|
||||
)
|
||||
)
|
||||
params = {}
|
||||
if mode == 'dgfip':
|
||||
params['mode'] = 'dgfip'
|
||||
if test:
|
||||
params['test'] = '1'
|
||||
if params:
|
||||
url += '?' + urlencode(params)
|
||||
return {
|
||||
'data': [
|
||||
{
|
||||
'id': '',
|
||||
'text': '',
|
||||
'init_request_url': url,
|
||||
'service_origin': get_url_origin(request.build_absolute_uri()),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def store(self, data):
|
||||
ref = str(uuid.uuid4().hex)
|
||||
cache.set(ref, data)
|
||||
return ref
|
||||
|
||||
def retrieve(self, ref):
|
||||
return cache.get(ref)
|
|
@ -0,0 +1,45 @@
|
|||
<html>
|
||||
<head>
|
||||
<script id="data" type="application/json">{{ data_json|safe }}</script>
|
||||
<script id="error" type="application/json">{{ error_json|safe }}</script>
|
||||
<script id="error_data" type="application/json">{{ error_data_json|safe }}</script>
|
||||
</head>
|
||||
<body>
|
||||
{% if test %}<button id="continue">Continue</button>
|
||||
<p>redirect_uri: <pre>{{ redirect_uri|pprint }}</pre></p>
|
||||
<p>correlation_id: <pre>{{ franceconnect.correlation_id }}</pre></p>
|
||||
{% if error %}
|
||||
<p>{{ error }}<p>
|
||||
{% if error.data %}
|
||||
<dl>
|
||||
{% for key, value in error.data %}
|
||||
<dt>{{ key }}</td>
|
||||
<dd><pre>{{ value|pprint }}</pre></dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<ul>
|
||||
{% for key, value in franceconnect.items reversed %}
|
||||
<li>{{ key }} : <pre>{{ value|pprint }}</pre></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<script>
|
||||
(function () {
|
||||
const data = JSON.parse(document.getElementById('data').textContent);
|
||||
const error = JSON.parse(document.getElementById('error').textContent);
|
||||
const error_data = JSON.parse(document.getElementById('error_data').textContent);
|
||||
const post_message = function () {
|
||||
{% if error %}window.opener.postMessage({"error": error, "data": error_data}, "{{ origin }}");
|
||||
{% else %}window.opener.postMessage(data, "{{ origin }}");{% endif %}
|
||||
}{% if test %}
|
||||
const continue_button = document.getElementById('continue');
|
||||
continue_button.addEventListener('click', function () { post_message(); });
|
||||
{% else %}
|
||||
post_message(){% endif %}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
{% extends "passerelle/manage.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
<a href="{{ resource.get_absolute_url }}">{{ resource.title }}</a>
|
||||
<a href="#">{% trans "Demo view" %}</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block appbar %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{% trans "Demo view" %}</h1>
|
||||
<p>
|
||||
<button id="start">{% trans "Get FranceConnect data" %}</button>
|
||||
</p>
|
||||
<p>
|
||||
<label for="dgfip">{% trans "Get DGFIP data" %}</label>
|
||||
<input type="checkbox" id="dgfip"/>
|
||||
</p>
|
||||
<script>
|
||||
function popupwindow(url, title, w, h) {
|
||||
var left = (screen.width/2)-(w/2);
|
||||
var top = (screen.height/2)-(h/2);
|
||||
return window.open(
|
||||
url, title,
|
||||
'toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=yes, resizable=no, copyhistory=no, width='+w+', height='+h+', top='+top+', left='+left);
|
||||
}
|
||||
var popup = null;
|
||||
|
||||
$('#start').on('click', function () {
|
||||
$('#user-info').hide();
|
||||
if (popup) { popup.close(); popup = null; };
|
||||
var url = 'init_request?test=1&origin={{ origin }}';
|
||||
var checkbox = document.getElementById('dgfip');
|
||||
if (checkbox && checkbox.checked) {
|
||||
url += '&mode=dgfip';
|
||||
};
|
||||
popup = popupwindow(url, 'FranceConnect', 1000, 670);
|
||||
if (window.focus) {
|
||||
popup.focus();
|
||||
}
|
||||
});
|
||||
$(window).on('message', function(event) {
|
||||
var data = event.originalEvent.data;
|
||||
var origin = event.originalEvent.origin;
|
||||
if (origin != window.location.origin) {
|
||||
return;
|
||||
}
|
||||
$.getJSON("data_source?id=" + data.id, function(result) {
|
||||
$('#user-info').show();
|
||||
$('#user-info-preview').text(JSON.stringify(result, null, 2));
|
||||
});
|
||||
popup.close();
|
||||
});
|
||||
</script>
|
||||
<div id="user-info" style="display: none; position: relative;">
|
||||
<h3>{% trans "Data-source data" %}</h3>
|
||||
<pre id="user-info-preview" style="background: white; border: 0.2ex solid black; color: black; padding: 1em; width: calc(100% - 5em);"></pre>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -0,0 +1,8 @@
|
|||
{% extends "passerelle/manage/service_view.html" %}
|
||||
{% load i18n passerelle %}
|
||||
|
||||
{% block description %}
|
||||
{{ block.super }}
|
||||
{% url "generic-endpoint" connector="franceconnect-data" slug=object.slug endpoint="callback" as callback_url %}
|
||||
<p>URL de callback pour FranceConnect: <a href="{{ request.scheme }}://{{ request.get_host }}{{ callback_url }}">{{ request.scheme }}://{{ request.get_host }}{{ callback_url }}</a></p>
|
||||
{% endblock %}
|
|
@ -141,6 +141,7 @@ INSTALLED_APPS = (
|
|||
'passerelle.apps.esirius',
|
||||
'passerelle.apps.family',
|
||||
'passerelle.apps.feeds',
|
||||
'passerelle.apps.franceconnect_data',
|
||||
'passerelle.apps.gdc',
|
||||
'passerelle.apps.gesbac',
|
||||
'passerelle.apps.jsondatastore',
|
||||
|
|
|
@ -0,0 +1,114 @@
|
|||
# Passerelle - uniform access to data and services
|
||||
# Copyright (C) 2021 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; exclude 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.deepcopy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import json
|
||||
from urllib.parse import parse_qs, urlparse, urlunparse
|
||||
|
||||
import pytest
|
||||
import utils
|
||||
from test_rsa13 import mock_response
|
||||
|
||||
from passerelle.apps.franceconnect_data.models import Resource
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fc(db):
|
||||
return utils.make_resource(
|
||||
Resource,
|
||||
slug='test',
|
||||
fc_platform_slug='prod',
|
||||
fc_client_id='1234',
|
||||
fc_client_secret='abcd',
|
||||
fc_scopes='identite_pivot',
|
||||
)
|
||||
|
||||
|
||||
def test_init_request(app, fc):
|
||||
resp = app.get('/franceconnect-data/test/init_request?origin=http://testserver')
|
||||
parsed_location = urlparse(resp.location)
|
||||
qs = parse_qs(parsed_location.query)
|
||||
assert (
|
||||
urlunparse(parsed_location._replace(query='')) == 'https://app.franceconnect.gouv.fr/api/v1/authorize'
|
||||
)
|
||||
assert dict(qs, nonce=None, state=None) == {
|
||||
'acr_values': ['eidas1'],
|
||||
'client_id': ['1234'],
|
||||
'nonce': None,
|
||||
'redirect_uri': ['http://testserver/franceconnect-data/test/callback?origin=http%3A%2F%2Ftestserver'],
|
||||
'response_type': ['code'],
|
||||
'scope': ['openid identite_pivot'],
|
||||
'state': None,
|
||||
}
|
||||
|
||||
|
||||
@mock_response(
|
||||
['/api/v1/token', {'access_token': 'at-1234', 'id_token': '.e30=.'}],
|
||||
[
|
||||
'/api/v1/userinfo',
|
||||
{
|
||||
'sub': 'sub-1234',
|
||||
'given_name': 'John',
|
||||
'family_name': 'Doe',
|
||||
'birthdate': '2001-04-28',
|
||||
'gender': 'male',
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_callback(app, fc):
|
||||
resp = app.get(
|
||||
'http://testserver/franceconnect-data/test/callback?origin=http%3A%2F%2Ftestserver&code=5678&raise=1'
|
||||
)
|
||||
data = json.loads(resp.pyquery('#data').text())
|
||||
assert data
|
||||
assert 'id' in data
|
||||
assert data['text'] == 'John Doe né le April 28, 2001'
|
||||
|
||||
resp = app.get('/franceconnect-data/test/data_source?id=' + data['id'])
|
||||
assert resp.json['err'] == 0
|
||||
assert dict(resp.json['data'][0], id=None) == {
|
||||
'franceconnect': {
|
||||
'birthdate': '2001-04-28',
|
||||
'family_name': 'Doe',
|
||||
'gender': 'male',
|
||||
'given_name': 'John',
|
||||
'sub': 'sub-1234',
|
||||
},
|
||||
'id': None,
|
||||
'text': 'John Doe né le April 28, 2001',
|
||||
}
|
||||
|
||||
|
||||
@mock_response(
|
||||
['/api/v1/token', ''],
|
||||
[
|
||||
'/api/v1/userinfo',
|
||||
{
|
||||
'sub': 'sub-1234',
|
||||
'given_name': 'John',
|
||||
'family_name': 'Doe',
|
||||
'birthdate': '2001-04-28',
|
||||
'gender': 'male',
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_callback_error(app, fc):
|
||||
resp = app.get(
|
||||
'http://testserver/franceconnect-data/test/callback?origin=http%3A%2F%2Ftestserver&code=5678&raise=1'
|
||||
)
|
||||
assert not resp.pyquery('#data').text()
|
||||
error = json.loads(resp.pyquery('#error').text())
|
||||
assert error
|
||||
assert 'Error in token endpoint response' in resp
|
|
@ -60,13 +60,16 @@ def mock_response(*path_contents):
|
|||
path, content = row
|
||||
query = None
|
||||
|
||||
@httmock.urlmatch(path=path)
|
||||
def handler(url, request):
|
||||
if query:
|
||||
assert parse_qs(url.query) == parse_qs(query), 'query does not match'
|
||||
return json.dumps(content)
|
||||
def make_handler(path, content):
|
||||
@httmock.urlmatch(path=path)
|
||||
def handler(url, request):
|
||||
if query:
|
||||
assert parse_qs(url.query) == parse_qs(query), 'query does not match'
|
||||
return json.dumps(content)
|
||||
|
||||
handlers.append(handler)
|
||||
return handler
|
||||
|
||||
handlers.append(make_handler(path, content))
|
||||
handlers.append(error)
|
||||
|
||||
with httmock.HTTMock(*handlers):
|
||||
|
|
Loading…
Reference in New Issue