Projet

Général

Profil

0003-add-france-connect-authentication-method.patch

Benjamin Dauvergne, 05 janvier 2017 22:19

Télécharger (20,9 ko)

Voir les différences:

Subject: [PATCH 3/4] add france connect authentication method

 wcs/admin/settings.py               |   2 +
 wcs/qommon/ident/france_connect.py  | 437 ++++++++++++++++++++++++++++++++++++
 wcs/qommon/publisher.py             |   2 +
 wcs/qommon/sessions.py              |  12 +
 wcs/qommon/static/css/dc2/admin.css |   4 +
 5 files changed, 457 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')))
74
            methods.insert(0,
75
                    ('fc', _('Delegated to France Connect')))
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, C_, flatten_dict
32
from .base import AuthMethod
33

  
34
ADMIN_TITLE = N_('France Connect')
35

  
36
# XXX: make an OIDC auth method that France Connect 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 France Connect 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

  
131
    CONFIG = [
132
        ('client_id', N_('Client ID')),
133
        ('client_secret', N_('Client secret')),
134
        ('platform', N_('Platform')),
135
        ('scopes', N_('Scopes')),
136
        ('user_field_mappings', N_('User field mappings')),
137
    ]
138

  
139
    KNOWN_ATTRIBUTES = [
140
        ('given_name', N_('fc_given_name_description|first names separated by spaces')),
141
        ('family_name', N_('fc_last_name_description|birth\'s last name')),
142
        ('birthdate', N_('fc_birthdate_description|birthdate formatted as YYYY-MM-DD')),
143
        ('gender', N_('fc_gender_description|gender \'male\' for men, and \'female\' for women')),
144
        ('birthplace', N_('fc_birthplace_description|INSEE code of the place of birth')),
145
        ('birthcountry', N_('fc_birthplace_description|INSEE code of the country of birth')),
146
        ('email', N_('fc_email_description|email')),
147
        ('siret', N_('fc_siret_description|SIRET or SIREN number of the enterprise')),
148
        # XXX: France Connect website also refer to adress and phones attributes but we don't know
149
        # what must be expected of their value.
150
    ]
151

  
152
    @classmethod
153
    def get_form(cls, instance={}):
154
        form = Form(enctype='multipart/form-data')
155
        for key, title in cls.CONFIG:
156
            attrs = {}
157
            default = None
158
            hint = None
159
            kwargs = {}
160
            widget = StringWidget
161

  
162
            if key == 'user_field_mappings':
163
                widget = UserFieldMappingTableWidget
164
            elif key == 'platform':
165
                widget = SingleSelectWidget
166
                kwargs['options'] = [
167
                    (platform['slug'], platform['name']) for platform in cls.PLAFTORMS
168
                ]
169
            elif key == 'scopes':
170
                default = 'identite_pivot address email phones'
171
                hint = _('Space separated values among: identite_pivot, address, email, phones, '
172
                         'profile, birth, preferred_username, gender, birthdate, '
173
                         'birthcountry, birthplace')
174
            if widget == StringWidget:
175
                kwargs['class'] = 'large'
176
            form.add(widget, key,
177
                     title=_(title),
178
                     hint=hint,
179
                     value=instance.get(key, default),
180
                     attrs=attrs, **kwargs)
181
        form.add_submit('submit', _('Submit'))
182
        return form
183

  
184
    def submit(self, form):
185
        cfg = {}
186
        for key, title in self.CONFIG:
187
            cfg[key] = form.get_widget(key).parse()
188
        get_publisher().cfg['fc'] = cfg
189
        get_publisher().write_cfg()
190
        return redirect('.')
191

  
192
    def _q_index(self):
193
        fc_cfg = get_cfg('fc', {})
194
        form = self.get_form(fc_cfg)
195
        pub = get_publisher()
196

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

  
219
            return r.getvalue()
220
        else:
221
            return self.submit(form)
222

  
223

  
224
class FCAuthMethod(AuthMethod):
225
    key = 'fc'
226
    description = ADMIN_TITLE
227
    method_directory = MethodDirectory
228
    method_admin_directory = MethodAdminDirectory
229

  
230
    def is_ok(self):
231
        fc_cfg = get_cfg('fc', {})
232
        for key, title in self.method_admin_directory.CONFIG:
233
            if not fc_cfg.get(key):
234
                return False
235
        return True
236

  
237
    def login(self):
238
        if not self.is_ok():
239
            return template.error_page(_('France Connect support is not yet configured'))
240

  
241
        fc_cfg = get_cfg('fc', {})
242
        pub = get_publisher()
243
        session = get_session()
244

  
245
        authorization_url = self.get_authorization_url()
246
        client_id = fc_cfg.get('client_id')
247
        state = str(uuid.uuid4())
248
        session = get_session()
249
        nonce = hashlib.sha256(str(session.id)).hexdigest()
250
        next_url = get_request().form.get('next') or pub.get_frontoffice_url()
251
        session.set_extra('fc_next_url_' + state, next_url)
252
        fc_callback = pub.get_frontoffice_url() + '/ident/fc/callback'
253
        qs = urllib.urlencode({
254
            'response_type': 'code',
255
            'client_id': client_id,
256
            'redirect_uri': fc_callback,
257
            'scope': 'openid identite_pivot email address phone',
258
            'state': state,
259
            'nonce': nonce,
260
        })
261
        redirect_url = '%s?%s' % (authorization_url, qs)
262
        return redirect(redirect_url)
263

  
264
    def is_interactive(self):
265
        return False
266

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

  
304
    def get_user_info(self, access_token):
305
        logger = get_logger()
306
        response, status, data, auth_header = http_get_page(
307
            self.get_user_info_url(),
308
            headers={
309
                'Authorization': 'Bearer %s' % access_token,
310
            })
311
        if status != 200:
312
            logger.error('status from France Connect user_info_url is not 200 but %s and data is'
313
                         ' %s', status. data[:100])
314
            return None
315
        return json.loads(data)
316

  
317
    def get_platform(self):
318
        fc_cfg = get_cfg('fc', {})
319
        slug = fc_cfg.get('platform')
320
        for platform in self.method_admin_directory.PLAFTORMS:
321
            if platform['slug'] == slug:
322
                return platform
323
        raise KeyError('platform %s not found' % slug)
324

  
325
    def get_authorization_url(self):
326
        return self.get_platform()['authorization_url']
327

  
328
    def get_token_url(self):
329
        return self.get_platform()['token_url']
330

  
331
    def get_user_info_url(self):
332
        return self.get_platform()['user_info_url']
333

  
334
    def get_logout_url(self):
335
        return self.get_platform()['logout_url']
336

  
337
    def fill_user_attributes(self, user, user_info):
338
        fc_cfg = get_cfg('fc', {})
339
        user_field_mappings = fc_cfg.get('user_field_mappings', [])
340
        user_formdef = get_publisher().user_class.get_formdef()
341

  
342
        form_data = user.form_data or {}
343
        user.verified_fields = user.verified_fields or []
344

  
345
        for user_field_mapping in user_field_mappings:
346
            field_varname = user_field_mapping['field_varname']
347
            value = user_field_mapping['value']
348
            verified = user_field_mapping['verified']
349
            field_id = None
350

  
351
            try:
352
                value = compute(value, context=user_info)
353
            except:
354
                continue
355
            if field_varname == '__name':
356
                user.name = value
357
            elif field_varname == '__email':
358
                user.email = value
359
                field_id = 'email'
360
            else:
361
                for field in user_formdef.fields:
362
                    if field_varname == field.varname:
363
                        field_id = str(field.id)
364
                        break
365
                else:
366
                    continue
367
                form_data[field.id] = value
368
            if field_varname == '__email':
369
                field_varname = 'email'  # special value for verified email field
370

  
371
            # Update verified fields
372
            if field_id:
373
                if verified == 'always' and field_id not in user.verified_fields:
374
                    user.verified_fields.append(field.id)
375
                elif verified != 'always' and field_id in user.verified_fields:
376
                    user.verified_fields.remove(field.id)
377

  
378
        user.form_data = form_data
379

  
380
        if user.form_data:
381
            user.set_attributes_from_formdata(user.form_data)
382

  
383
    AUTHORIZATION_REQUEST_ERRORS = {
384
        'access_denied': N_('You refused the connection'),
385
    }
386

  
387
    def callback(self):
388
        if not self.is_ok():
389
            return template.error_page(_('France Connect support is not yet configured'))
390
        pub = get_publisher()
391
        request = get_request()
392
        session = get_session()
393
        logger = get_logger()
394
        state = request.form.get('state', '')
395
        next_url = session.pop_extra('fc_next_url_' + state, '') or pub.get_frontoffice_url()
396

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

  
423
        #  Lookup or create user
424
        sub = user_info['sub']
425
        user = None
426
        for user in pub.user_class.get_users_with_name_identifier(sub):
427
            break
428
        if not user:
429
            user = pub.user_class(sub)
430
            user.name_identifiers = [sub]
431

  
432
        # XXX: should we fail login if user info is not sufficient as with SAML 2.0 login ?
433
        # XXX: should we implement an authentication less mode, it would just store FC user informations into the session variables
434
        self.fill_user_attributes(user, user_info)
435
        user.store()
436
        session.set_user(user.id)
437
        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.france_connect
674
        classes.append(qommon.ident.france_connect.FCAuthMethod)
673 675
        import qommon.ident.password
674 676
        classes.append(qommon.ident.password.PasswordAuthMethod)
675 677
        self.ident_methods = {}
wcs/qommon/sessions.py
81 81
    jsonp_display_values = None
82 82
    extra_variables = None
83 83
    expire = None
84
    extra = {}
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 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(self, key, value):
244
        self.extra = self.extra = {}
245
        self.extra[key] = value
246

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

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

  
241 253

  
242 254
class QommonSessionManager(QuixoteSessionManager):
243 255
    def start_request(self):
wcs/qommon/static/css/dc2/admin.css
1550 1550
.ui-dialog .ui-widget-content .ui-state-default.submit-button:hover {
1551 1551
	border-color: #283c94;
1552 1552
}
1553

  
1554
div.widget div.content input.large {
1555
	width: 100%;
1556
}
1553
-