Projet

Général

Profil

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

Benjamin Dauvergne, 06 janvier 2017 14:31

Télécharger (20,8 ko)

Voir les différences:

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

 wcs/admin/settings.py              |   2 +
 wcs/qommon/ident/france_connect.py | 445 +++++++++++++++++++++++++++++++++++++
 wcs/qommon/publisher.py            |   2 +
 wcs/qommon/sessions.py             |  12 +
 4 files changed, 461 insertions(+)
 create mode 100644 wcs/qommon/ident/france_connect.py
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/france_connect.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
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
        nonce = hashlib.sha256(str(session.id)).hexdigest()
258
        next_url = get_request().form.get('next') or pub.get_frontoffice_url()
259
        session.set_extra_attribute('fc_next_url_' + state, next_url)
260
        fc_callback = pub.get_frontoffice_url() + '/ident/fc/callback'
261
        qs = urllib.urlencode({
262
            'response_type': 'code',
263
            'client_id': client_id,
264
            'redirect_uri': fc_callback,
265
            'scope': 'openid ' + fc_cfg.get('scopes', ''),
266
            'state': state,
267
            'nonce': nonce,
268
        })
269
        redirect_url = '%s?%s' % (authorization_url, qs)
270
        return redirect(redirect_url)
271

  
272
    def is_interactive(self):
273
        return False
274

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

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

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

  
333
    def get_authorization_url(self):
334
        return self.get_platform()['authorization_url']
335

  
336
    def get_token_url(self):
337
        return self.get_platform()['token_url']
338

  
339
    def get_user_info_url(self):
340
        return self.get_platform()['user_info_url']
341

  
342
    def get_logout_url(self):
343
        return self.get_platform()['logout_url']
344

  
345
    def fill_user_attributes(self, user, user_info):
346
        fc_cfg = get_cfg('fc', {})
347
        user_field_mappings = fc_cfg.get('user_field_mappings', [])
348
        user_formdef = get_publisher().user_class.get_formdef()
349

  
350
        form_data = user.form_data or {}
351
        user.verified_fields = user.verified_fields or []
352

  
353
        for user_field_mapping in user_field_mappings:
354
            field_varname = user_field_mapping['field_varname']
355
            value = user_field_mapping['value']
356
            verified = user_field_mapping['verified']
357
            field_id = None
358

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

  
379
            # Update verified fields
380
            if field_id:
381
                if verified == 'always' and field_id not in user.verified_fields:
382
                    user.verified_fields.append(field.id)
383
                elif verified != 'always' and field_id in user.verified_fields:
384
                    user.verified_fields.remove(field.id)
385

  
386
        user.form_data = form_data
387

  
388
        if user.form_data:
389
            user.set_attributes_from_formdata(user.form_data)
390

  
391
    AUTHORIZATION_REQUEST_ERRORS = {
392
        'access_denied': N_('You refused the connection'),
393
    }
394

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

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

  
431
        #  Lookup or create user
432
        sub = user_info['sub']
433
        user = None
434
        for user in pub.user_class.get_users_with_name_identifier(sub):
435
            break
436
        if not user:
437
            user = pub.user_class(sub)
438
            user.name_identifiers = [sub]
439

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

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

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

  
......
238 240
                d[prefix + k] = v
239 241
        return d
240 242

  
243
    def set_extra_attribute(self, key, value):
244
        self.extra_attributes = self.extra_attributes = {}
245
        self.extra_attributes[key] = value
246

  
247
    def get_extra_attribute(self, key, default=None):
248
        return (self.extra_attributes or {}).get(key, default)
249

  
250
    def pop_extra_attribute(self, key, default=None):
251
        return (self.extra_attributes or {}).pop(key, default)
252

  
241 253

  
242 254
class QommonSessionManager(QuixoteSessionManager):
243 255
    def start_request(self):
244
-