Projet

Général

Profil

0003-add-FranceConnect-authentication-method-fixes-14510.patch

Benjamin Dauvergne, 17 février 2017 11:03

Télécharger (27,9 ko)

Voir les différences:

Subject: [PATCH 3/4] add FranceConnect authentication method (fixes #14510)

 tests/test_fc_auth.py             | 205 +++++++++++++++++
 wcs/admin/settings.py             |   2 +
 wcs/qommon/ident/franceconnect.py | 449 ++++++++++++++++++++++++++++++++++++++
 wcs/qommon/publisher.py           |   2 +
 wcs/qommon/sessions.py            |  12 +
 5 files changed, 670 insertions(+)
 create mode 100644 tests/test_fc_auth.py
 create mode 100644 wcs/qommon/ident/franceconnect.py
tests/test_fc_auth.py
1
import urlparse
2
import base64
3
import json
4
import urllib
5

  
6
from quixote import cleanup, get_session_manager
7

  
8
from utilities import get_app, create_temporary_pub
9
import mock
10

  
11
PROFILE = {
12
    'fields': [
13
      {
14
        'kind': 'string',
15
        'description': '',
16
        'required': True,
17
        'user_visible': True,
18
        'label': u'Prenoms',
19
        'disabled': False,
20
        'user_editable': True,
21
        'asked_on_registration': True,
22
        'name': 'prenoms'
23
      },
24
      {
25
        'kind': 'string',
26
        'description': '',
27
        'required': True,
28
        'user_visible': True,
29
        'label': 'Nom',
30
        'disabled': False,
31
        'user_editable': True,
32
        'asked_on_registration': True,
33
        'name': 'nom'
34
      },
35
      {
36
        'kind': 'string',
37
        'description': '',
38
        'required': True,
39
        'user_visible': True,
40
        'label': 'Email',
41
        'disabled': False,
42
        'user_editable': True,
43
        'asked_on_registration': True,
44
        'name': 'email'
45
      },
46
    ]
47
}
48

  
49

  
50
def base64url_encode(v):
51
    return base64.urlsafe_b64encode(v).strip('=')
52

  
53

  
54
def setup_module(module):
55
    cleanup()
56
    global pub
57
    pub = create_temporary_pub()
58

  
59

  
60
def setup_user_profile(pub):
61
    if not pub.cfg:
62
        pub.cfg = {}
63
    # create some roles
64
    from wcs.ctl.check_hobos import CmdCheckHobos
65

  
66
    # setup an hobo profile
67
    CmdCheckHobos().update_profile(PROFILE, pub)
68
    pub.cfg['users']['field_name'] = ['_prenoms', '_nom']
69
    pub.user_class.wipe()
70
    pub.write_cfg()
71

  
72
FC_CONFIG = {
73
    'client_id': '123',
74
    'client_secret': 'xyz',
75
    'platform': 'dev-particulier',
76
    'scopes': 'identite_pivot',
77
    'user_field_mappings': [
78
        {
79
            'field_varname': 'prenoms',
80
            'value': '[given_name]',
81
            'verified': 'always',
82
        },
83
        {
84
            'field_varname': 'nom',
85
            'value': '[family_name]',
86
            'verified': 'always',
87
        },
88
        {
89
            'field_varname': 'email',
90
            'value': '[email]',
91
            'verified': 'always',
92
        },
93
    ]
94

  
95
}
96

  
97

  
98
def setup_fc_environment(pub):
99
    if not pub.cfg:
100
        pub.cfg = {}
101
    pub.cfg['identification'] = {
102
        'methods': ['fc'],
103
    }
104
    pub.cfg['fc'] = FC_CONFIG
105
    pub.user_class.wipe()
106
    pub.write_cfg()
107

  
108

  
109
def test_fc_login_page():
110
    setup_user_profile(pub)
111
    setup_fc_environment(pub)
112
    app = get_app(pub)
113
    resp = app.get('/')
114
    resp = app.get('/login/')
115
    assert resp.status_int == 302
116
    assert resp.location.startswith('https://fcp.integ01.dev-franceconnect.fr/api/v1/authorize')
117
    qs = urlparse.parse_qs(resp.location.split('?')[1])
118
    nonce = qs['nonce'][0]
119
    state = qs['state'][0]
120

  
121
    id_token = {
122
        'nonce': nonce,
123
    }
124
    token_result = {
125
        'access_token': 'abcd',
126
        'id_token': '.%s.' % base64url_encode(json.dumps(id_token)),
127
    }
128
    user_info_result = {
129
        'sub': 'ymca',
130
        'given_name': 'John',
131
        'family_name': 'Doe',
132
        'email': 'john.doe@example.com',
133
    }
134

  
135
    assert pub.user_class.count() == 0
136
    with mock.patch('qommon.ident.franceconnect.http_post_request') as http_post_request, \
137
            mock.patch('qommon.ident.franceconnect.http_get_page') as http_get_page:
138
        http_post_request.return_value = (None, 200, json.dumps(token_result), None)
139
        http_get_page.return_value = (None, 200, json.dumps(user_info_result), None)
140
        resp = app.get('/ident/fc/callback?%s' % urllib.urlencode({
141
            'code': '1234', 'state': state,
142
        }))
143
    assert pub.user_class.count() == 1
144
    user = pub.user_class.select()[0]
145
    assert user.form_data == {'_email': 'john.doe@example.com', '_nom': 'Doe', '_prenoms': 'John'}
146
    assert set(user.verified_fields) == set(['_nom', '_prenoms', '_email'])
147
    assert user.email == 'john.doe@example.com'
148
    assert user.name_identifiers == ['ymca']
149
    assert user.name == 'John Doe'
150

  
151
    # Verify we are logged in
152
    session_id = app.cookies.values()[0].strip('"')
153
    session = get_session_manager().session_class.get(session_id)
154
    assert session.user == user.id
155
    assert session.extra_variables['fc_user_given_name'] == 'John'
156
    assert session.extra_variables['fc_user_family_name'] == 'Doe'
157
    assert session.extra_variables['fc_user_email'] == 'john.doe@example.com'
158
    assert session.extra_variables['fc_user_sub'] == 'ymca'
159

  
160
    # Login existing user
161
    resp = app.get('/logout')
162
    resp = app.get('/login/')
163
    with mock.patch('qommon.ident.franceconnect.http_post_request') as http_post_request, \
164
            mock.patch('qommon.ident.franceconnect.http_get_page') as http_get_page:
165
        http_post_request.return_value = (None, 200, json.dumps(token_result), None)
166
        http_get_page.return_value = (None, 200, json.dumps(user_info_result), None)
167
        resp = app.get('/ident/fc/callback?%s' % urllib.urlencode({
168
            'code': '1234', 'state': state,
169
        }))
170
    new_session_id = app.cookies.values()[0].strip('"')
171
    assert session_id != new_session_id, 'no new session created'
172
    session = get_session_manager().session_class.get(new_session_id)
173
    assert pub.user_class.count() == 1, 'existing user has not been used'
174

  
175

  
176
def test_fc_settings():
177
    setup_user_profile(pub)
178
    app = get_app(pub)
179
    resp = app.get('/backoffice/settings/identification/')
180
    resp.forms[0]['methods$elementfc'].checked = True
181
    resp = resp.forms[0].submit().follow()
182

  
183
    assert 'FranceConnect' in resp.body
184
    resp = resp.click('FranceConnect')
185
    resp = resp.forms[0].submit('user_field_mappings$add_element')
186
    resp = resp.forms[0].submit('user_field_mappings$add_element')
187
    resp.forms[0]['client_id'].value = '123'
188
    resp.forms[0]['client_secret'].value = 'xyz'
189
    resp.forms[0]['platform'].value = 'Development citizens'
190
    resp.forms[0]['scopes'].value = 'identite_pivot'
191

  
192
    resp.forms[0]['user_field_mappings$element0$field_varname'] = 'prenoms'
193
    resp.forms[0]['user_field_mappings$element0$value'] = '[given_name]'
194
    resp.forms[0]['user_field_mappings$element0$verified'] = 'Always'
195

  
196
    resp.forms[0]['user_field_mappings$element1$field_varname'] = 'nom'
197
    resp.forms[0]['user_field_mappings$element1$value'] = '[family_name]'
198
    resp.forms[0]['user_field_mappings$element1$verified'] = 'Always'
199

  
200
    resp.forms[0]['user_field_mappings$element2$field_varname'] = 'email'
201
    resp.forms[0]['user_field_mappings$element2$value'] = '[email]'
202
    resp.forms[0]['user_field_mappings$element2$verified'] = 'Always'
203

  
204
    resp = resp.forms[0].submit('submit').follow()
205
    assert pub.cfg['fc'] == FC_CONFIG
wcs/admin/settings.py
71 71
        if lasso is not None:
72 72
            methods.insert(0,
73 73
                    ('idp', _('Delegated to SAML identity provider'), 'idp'))
74
            methods.insert(0,
75
                    ('fc', _('Delegated to France Connect'), 'fc'))
74 76
        form.add(CheckboxesWidget, 'methods', title = _('Methods'),
75 77
                value=identification_cfg.get('methods'),
76 78
                options=methods,
wcs/qommon/ident/franceconnect.py
1
# w.c.s. - web application for online forms
2
# Copyright (C) 2005-2017  Entr'ouvert
3
#
4
# This program is free software; you can redistribute it and/or modify
5
# it under the terms of the GNU General Public License as published by
6
# the Free Software Foundation; either version 2 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU General Public License for more details.
13
#
14
# You should have received a copy of the GNU General Public License
15
# along with this program; if not, see <http://www.gnu.org/licenses/>.
16

  
17
import json
18
import urllib
19
import uuid
20
import hashlib
21
import base64
22

  
23
from quixote import redirect, get_session, get_publisher, get_request, get_session_manager
24
from quixote.directory import Directory
25
from quixote.html import htmltext, TemplateIO
26

  
27
from qommon.backoffice.menu import html_top
28
from qommon import template, get_cfg, get_logger
29
from qommon.form import (Form, StringWidget, CompositeWidget, ComputedExpressionWidget,
30
                         RadiobuttonsWidget, SingleSelectWidget, WidgetListAsTable)
31
from qommon.misc import http_post_request, http_get_page, compute, json_loads, flatten_dict
32
from .base import AuthMethod
33

  
34
ADMIN_TITLE = N_('FranceConnect')
35

  
36
# XXX: make an OIDC auth method that FranceConnect would inherit from
37

  
38

  
39
def base64url_decode(input):
40
    rem = len(input) % 4
41
    if rem > 0:
42
        input += b'=' * (4 - rem)
43
    return base64.urlsafe_b64decode(input)
44

  
45

  
46
class UserFieldMappingRowWidget(CompositeWidget):
47
    def __init__(self, name, value=None, **kwargs):
48
        CompositeWidget.__init__(self, name, value, **kwargs)
49
        if not value:
50
            value = {}
51

  
52
        fields = []
53
        users_cfg = get_cfg('users', {})
54
        user_formdef = get_publisher().user_class.get_formdef()
55
        if not user_formdef or not users_cfg.get('field_name'):
56
            fields.append(('__name', _('Name'), '__name'))
57
        if not user_formdef or not users_cfg.get('field_email'):
58
            fields.append(('__email', _('Email'), '__email'))
59
        if user_formdef and user_formdef.fields:
60
            for field in user_formdef.fields:
61
                if field.varname:
62
                    fields.append((field.varname, field.label, field.varname))
63

  
64
        self.add(SingleSelectWidget, name='field_varname', title=_('Field'),
65
                 value=value.get('field_varname'),
66
                 options=fields, **kwargs)
67
        self.add(ComputedExpressionWidget, name='value', title=_('Value'),
68
                 value=value.get('value'))
69
        self.add(SingleSelectWidget, 'verified',
70
                 title=_('Is attribute verified'),
71
                 value=value.get('verified'),
72
                 options=[('never', _('Never')),
73
                          ('always', _('Always'))
74
                          ]
75
                 )
76

  
77
    def _parse(self, request):
78
        if self.get('value') and self.get('field_varname') and self.get('verified'):
79
            self.value = {
80
                'value': self.get('value'),
81
                'field_varname': self.get('field_varname'),
82
                'verified': self.get('verified'),
83
            }
84
        else:
85
            self.value = None
86

  
87

  
88
class UserFieldMappingTableWidget(WidgetListAsTable):
89
    readonly = False
90

  
91
    def __init__(self, name, **kwargs):
92
        super(UserFieldMappingTableWidget, self).__init__(
93
            name, element_type=UserFieldMappingRowWidget, **kwargs)
94

  
95

  
96
class MethodDirectory(Directory):
97
    _q_exports = ['login', 'callback']
98

  
99
    def login(self):
100
        return FCAuthMethod().login()
101

  
102
    def callback(self):
103
        return FCAuthMethod().callback()
104

  
105

  
106
class MethodAdminDirectory(Directory):
107
    title = ADMIN_TITLE
108
    label = N_('Configure FranceConnect identification method')
109

  
110
    _q_exports = ['']
111

  
112
    PLAFTORMS = [
113
        {
114
            'name': N_('Development citizens'),
115
            'slug': 'dev-particulier',
116
            'authorization_url': 'https://fcp.integ01.dev-franceconnect.fr/api/v1/authorize',
117
            'token_url': 'https://fcp.integ01.dev-franceconnect.fr/api/v1/token',
118
            'user_info_url': 'https://fcp.integ01.dev-franceconnect.fr/api/v1/userinfo',
119
            'logout_url': 'https://fcp.integ01.dev-franceconnect.fr/api/v1/logout',
120
        },
121
        {
122
            'name': N_('Development enterprise'),
123
            'slug': 'dev-entreprise',
124
            'authorization_url': 'https://fce.integ01.dev-franceconnect.fr/api/v1/authorize',
125
            'token_url': 'https://fce.integ01.dev-franceconnect.fr/api/v1/token',
126
            'user_info_url': 'https://fce.integ01.dev-franceconnect.fr/api/v1/userinfo',
127
            'logout_url': 'https://fce.integ01.dev-franceconnect.fr/api/v1/logout',
128
        },
129
        {
130
            'name': N_('Production citizens'),
131
            'slug': 'prod-particulier',
132
            'authorization_url': 'https://app.franceconnect.gouv.fr/api/v1/authorize',
133
            'token_url': 'https://app.franceconnect.gouv.fr/api/v1/token',
134
            'user_info_url': 'https://app.franceconnect.gouv.fr/api/v1/userinfo',
135
            'logout_url': 'https://app.franceconnect.gouv.fr/api/v1/logout',
136
        }
137
    ]
138

  
139
    CONFIG = [
140
        ('client_id', N_('Client ID')),
141
        ('client_secret', N_('Client secret')),
142
        ('platform', N_('Platform')),
143
        ('scopes', N_('Scopes')),
144
        ('user_field_mappings', N_('User field mappings')),
145
    ]
146

  
147
    KNOWN_ATTRIBUTES = [
148
        ('given_name', N_('first names separated by spaces')),
149
        ('family_name', N_('birth\'s last name')),
150
        ('birthdate', N_('birthdate formatted as YYYY-MM-DD')),
151
        ('gender', N_('gender \'male\' for men, and \'female\' for women')),
152
        ('birthplace', N_('INSEE code of the place of birth')),
153
        ('birthcountry', N_('INSEE code of the country of birth')),
154
        ('email', N_('email')),
155
        ('siret', N_('SIRET or SIREN number of the enterprise')),
156
        # XXX: FranceConnect website also refer to adress and phones attributes but we don't know
157
        # what must be expected of their value.
158
    ]
159

  
160
    @classmethod
161
    def get_form(cls, instance={}):
162
        form = Form(enctype='multipart/form-data')
163
        for key, title in cls.CONFIG:
164
            attrs = {}
165
            default = None
166
            hint = None
167
            kwargs = {}
168
            widget = StringWidget
169

  
170
            if key == 'user_field_mappings':
171
                widget = UserFieldMappingTableWidget
172
            elif key == 'platform':
173
                widget = SingleSelectWidget
174
                kwargs['options'] = [
175
                    (platform['slug'], platform['name']) for platform in cls.PLAFTORMS
176
                ]
177
            elif key == 'scopes':
178
                default = 'identite_pivot address email phones'
179
                hint = _('Space separated values among: identite_pivot, address, email, phones, '
180
                         'profile, birth, preferred_username, gender, birthdate, '
181
                         'birthcountry, birthplace')
182
            if widget == StringWidget:
183
                kwargs['size'] = '80'
184
            form.add(widget, key,
185
                     title=_(title),
186
                     hint=hint,
187
                     value=instance.get(key, default),
188
                     attrs=attrs, **kwargs)
189
        form.add_submit('submit', _('Submit'))
190
        return form
191

  
192
    def submit(self, form):
193
        cfg = {}
194
        for key, title in self.CONFIG:
195
            cfg[key] = form.get_widget(key).parse()
196
        get_publisher().cfg['fc'] = cfg
197
        get_publisher().write_cfg()
198
        return redirect('.')
199

  
200
    def _q_index(self):
201
        fc_cfg = get_cfg('fc', {})
202
        form = self.get_form(fc_cfg)
203
        pub = get_publisher()
204

  
205
        if not ('submit' in get_request().form and form.is_submitted()) or form.has_errors():
206
            html_top('settings', title=_(self.title))
207
            r = TemplateIO(html=True)
208
            r += htmltext('<h2>%s</h2>') % self.title
209
            fc_callback = pub.get_frontoffice_url() + '/ident/fc/callback'
210
            r += htmltext(_('<p>Callback URL is <a href="%s">%s</a>.</p>')) % (
211
                fc_callback, fc_callback)
212
            r += htmltext(_('See <a target="_blank" href="https://franceconnect.gouv.fr/fournisseur-service">'
213
                            'FranceConnect partners\'site</a> for getting a client_id and '
214
                            'a client_secret.</p>'))
215
            r += form.render()
216
            r += htmltext('<div><p>See <a '
217
                          'href="https://franceconnect.gouv.fr/fournisseur-service#identite-pivot" '
218
                          'target="_blank">FranceConnect partners\'site</a> for more '
219
                          'informations on available scopes and attributes. Known ones '
220
                          'are&nbsp;:<p>')
221
            r += (htmltext('<table><thead><tr><th>%s</th><th>%s</th></tr></thead><tbody>') %
222
                  (_('Attribute'), _('Description')))
223
            for attribute, description in self.KNOWN_ATTRIBUTES:
224
                r += htmltext('<tr><td><pre>%s</pre></td><td>%s</td></tr>') % (attribute, _(description))
225
            r += htmltext('</tbody></table></div>')
226

  
227
            return r.getvalue()
228
        else:
229
            return self.submit(form)
230

  
231

  
232
class FCAuthMethod(AuthMethod):
233
    key = 'fc'
234
    description = ADMIN_TITLE
235
    method_directory = MethodDirectory
236
    method_admin_directory = MethodAdminDirectory
237

  
238
    def is_ok(self):
239
        fc_cfg = get_cfg('fc', {})
240
        for key, title in self.method_admin_directory.CONFIG:
241
            if not fc_cfg.get(key):
242
                return False
243
        return True
244

  
245
    def login(self):
246
        if not self.is_ok():
247
            return template.error_page(_('FranceConnect support is not yet configured'))
248

  
249
        fc_cfg = get_cfg('fc', {})
250
        pub = get_publisher()
251
        session = get_session()
252

  
253
        authorization_url = self.get_authorization_url()
254
        client_id = fc_cfg.get('client_id')
255
        state = str(uuid.uuid4())
256
        session = get_session()
257
        next_url = get_request().form.get('next') or pub.get_frontoffice_url()
258
        session.set_extra_attribute('fc_next_url_' + state, next_url)
259

  
260
        # generate a session id if none exists, ugly but necessary
261
        get_session_manager().maintain_session(session)
262

  
263
        nonce = hashlib.sha256(str(session.id)).hexdigest()
264
        fc_callback = pub.get_frontoffice_url() + '/ident/fc/callback'
265
        qs = urllib.urlencode({
266
            'response_type': 'code',
267
            'client_id': client_id,
268
            'redirect_uri': fc_callback,
269
            'scope': 'openid ' + fc_cfg.get('scopes', ''),
270
            'state': state,
271
            'nonce': nonce,
272
        })
273
        redirect_url = '%s?%s' % (authorization_url, qs)
274
        return redirect(redirect_url)
275

  
276
    def is_interactive(self):
277
        return False
278

  
279
    def get_access_token(self, code):
280
        logger = get_logger()
281
        session = get_session()
282
        fc_cfg = get_cfg('fc', {})
283
        client_id = fc_cfg.get('client_id')
284
        client_secret = fc_cfg.get('client_secret')
285
        redirect_uri = get_request().get_frontoffice_url().split('?')[0]
286
        body = {
287
            'grant_type': 'authorization_code',
288
            'redirect_uri': redirect_uri,
289
            'client_id': client_id,
290
            'client_secret': client_secret,
291
            'code': code,
292
        }
293
        response, status, data, auth_header = http_post_request(
294
            self.get_token_url(),
295
            urllib.urlencode(body),
296
            headers={
297
                'Content-Type': 'application/x-www-form-urlencoded',
298
            })
299
        if status != 200:
300
            logger.error('status from FranceConnect token_url is not 200')
301
            return None
302
        result = json_loads(data)
303
        if 'error' in result:
304
            logger.error('FranceConnect code resolution failed: %s', result['error'])
305
            return None
306
        # check id_token nonce
307
        id_token = result['id_token']
308
        header, payload, signature = id_token.split('.')
309
        payload = json_loads(base64url_decode(payload))
310
        nonce = hashlib.sha256(str(session.id)).hexdigest()
311
        if payload['nonce'] != nonce:
312
            logger.error('FranceConnect returned nonce did not match')
313
            return None
314
        return result['access_token']
315

  
316
    def get_user_info(self, access_token):
317
        logger = get_logger()
318
        response, status, data, auth_header = http_get_page(
319
            self.get_user_info_url(),
320
            headers={
321
                'Authorization': 'Bearer %s' % access_token,
322
            })
323
        if status != 200:
324
            logger.error('status from FranceConnect user_info_url is not 200 but %s and data is'
325
                         ' %s', status. data[:100])
326
            return None
327
        return json.loads(data)
328

  
329
    def get_platform(self):
330
        fc_cfg = get_cfg('fc', {})
331
        slug = fc_cfg.get('platform')
332
        for platform in self.method_admin_directory.PLAFTORMS:
333
            if platform['slug'] == slug:
334
                return platform
335
        raise KeyError('platform %s not found' % slug)
336

  
337
    def get_authorization_url(self):
338
        return self.get_platform()['authorization_url']
339

  
340
    def get_token_url(self):
341
        return self.get_platform()['token_url']
342

  
343
    def get_user_info_url(self):
344
        return self.get_platform()['user_info_url']
345

  
346
    def get_logout_url(self):
347
        return self.get_platform()['logout_url']
348

  
349
    def fill_user_attributes(self, user, user_info):
350
        fc_cfg = get_cfg('fc', {})
351
        user_field_mappings = fc_cfg.get('user_field_mappings', [])
352
        user_formdef = get_publisher().user_class.get_formdef()
353

  
354
        form_data = user.form_data or {}
355
        user.verified_fields = user.verified_fields or []
356

  
357
        for user_field_mapping in user_field_mappings:
358
            field_varname = user_field_mapping['field_varname']
359
            value = user_field_mapping['value']
360
            verified = user_field_mapping['verified']
361
            field_id = None
362

  
363
            try:
364
                value = compute(value, context=user_info)
365
            except:
366
                continue
367
            if field_varname == '__name':
368
                user.name = value
369
            elif field_varname == '__email':
370
                user.email = value
371
                field_id = 'email'
372
            else:
373
                for field in user_formdef.fields:
374
                    if field_varname == field.varname:
375
                        field_id = str(field.id)
376
                        break
377
                else:
378
                    continue
379
                form_data[field.id] = value
380
            if field_varname == '__email':
381
                field_varname = 'email'  # special value for verified email field
382

  
383
            # Update verified fields
384
            if field_id:
385
                if verified == 'always' and field_id not in user.verified_fields:
386
                    user.verified_fields.append(field.id)
387
                elif verified != 'always' and field_id in user.verified_fields:
388
                    user.verified_fields.remove(field.id)
389

  
390
        user.form_data = form_data
391

  
392
        if user.form_data:
393
            user.set_attributes_from_formdata(user.form_data)
394

  
395
    AUTHORIZATION_REQUEST_ERRORS = {
396
        'access_denied': N_('You refused the connection'),
397
    }
398

  
399
    def callback(self):
400
        if not self.is_ok():
401
            return template.error_page(_('FranceConnect support is not yet configured'))
402
        pub = get_publisher()
403
        request = get_request()
404
        session = get_session()
405
        logger = get_logger()
406
        state = request.form.get('state', '')
407
        next_url = session.pop_extra_attribute('fc_next_url_' + state, '') or pub.get_frontoffice_url()
408

  
409
        if 'code' not in request.form:
410
            error = request.form.get('error')
411
            # if no error parameter, we stay silent
412
            if error:
413
                # we log only errors whose user is not responsible
414
                msg = _(self.AUTHORIZATION_REQUEST_ERRORS.get(error))
415
                if not msg:
416
                    msg = _('FranceConnect authentication failed')
417
                    logger.error('FranceConnect authentication failed with an unknown error: %s',
418
                                 error)
419
                session.message = ('error', msg)
420
            return redirect(next_url)
421
        access_token = self.get_access_token(request.form['code'])
422
        if not access_token:
423
            return redirect(next_url)
424
        user_info = self.get_user_info(access_token)
425
        if not user_info:
426
            return redirect(next_url)
427
        # Store user info in session
428
        flattened_user_info = user_info.copy()
429
        flatten_dict(flattened_user_info)
430
        session_var_fc_user = {}
431
        for key in flattened_user_info:
432
            session_var_fc_user['fc_user_' + key] = flattened_user_info[key]
433
        session.add_extra_variables(**session_var_fc_user)
434

  
435
        #  Lookup or create user
436
        sub = user_info['sub']
437
        user = None
438
        for user in pub.user_class.get_users_with_name_identifier(sub):
439
            break
440
        if not user:
441
            user = pub.user_class(sub)
442
            user.name_identifiers = [sub]
443

  
444
        # XXX: should we fail login if user info is not sufficient as with SAML 2.0 login ?
445
        # XXX: should we implement an authentication less mode, it would just store FC user informations into the session variables
446
        self.fill_user_attributes(user, user_info)
447
        user.store()
448
        session.set_user(user.id)
449
        return redirect(next_url)
wcs/qommon/publisher.py
670 670
        if lasso:
671 671
            import qommon.ident.idp
672 672
            classes.append(qommon.ident.idp.IdPAuthMethod)
673
        import qommon.ident.franceconnect
674
        classes.append(qommon.ident.franceconnect.FCAuthMethod)
673 675
        import qommon.ident.password
674 676
        classes.append(qommon.ident.password.PasswordAuthMethod)
675 677
        self.ident_methods = {}
wcs/qommon/sessions.py
82 82
    jsonp_display_values = None
83 83
    extra_variables = None
84 84
    expire = None
85
    extra_attributes = None
85 86

  
86 87
    username = None # only set on password authentication
87 88

  
......
116 117
            self.extra_variables or \
117 118
            CaptchaSession.has_info(self) or \
118 119
            self.expire or \
120
            self.extra_attributes or \
119 121
            QuixoteSession.has_info(self)
120 122
    is_dirty = has_info
121 123

  
......
248 250
                d[prefix + k] = v
249 251
        return d
250 252

  
253
    def set_extra_attribute(self, key, value):
254
        self.extra_attributes = self.extra_attributes = {}
255
        self.extra_attributes[key] = value
256

  
257
    def get_extra_attribute(self, key, default=None):
258
        return (self.extra_attributes or {}).get(key, default)
259

  
260
    def pop_extra_attribute(self, key, default=None):
261
        return (self.extra_attributes or {}).pop(key, default)
262

  
251 263

  
252 264
class QommonSessionManager(QuixoteSessionManager):
253 265
    def start_request(self):
254
-