Projet

Général

Profil

0001-authenticators-add-import-export-65360.patch

Valentin Deniaud, 06 octobre 2022 16:51

Télécharger (22,4 ko)

Voir les différences:

Subject: [PATCH] authenticators: add import/export (#65360)

 src/authentic2/apps/authenticators/forms.py   |  13 ++
 .../apps/authenticators/manager_urls.py       |   2 +
 src/authentic2/apps/authenticators/models.py  | 102 ++++++++++-
 .../authenticators/authenticator_detail.html  |   1 +
 .../authenticators/authenticators.html        |   4 +
 src/authentic2/apps/authenticators/views.py   |  55 +++++-
 tests/test_manager_authenticators.py          | 169 +++++++++++++++++-
 7 files changed, 339 insertions(+), 7 deletions(-)
src/authentic2/apps/authenticators/forms.py
14 14
# You should have received a copy of the GNU Affero General Public License
15 15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 16

  
17
import json
18

  
17 19
from django import forms
20
from django.core.exceptions import ValidationError
18 21
from django.db.models import Max
19 22
from django.utils.translation import ugettext as _
20 23

  
......
54 57
        return super().save()
55 58

  
56 59

  
60
class AuthenticatorImportForm(forms.Form):
61
    authenticator_json = forms.FileField(label=_('Authenticator export file'))
62

  
63
    def clean_authenticator_json(self):
64
        try:
65
            return json.loads(self.cleaned_data['authenticator_json'].read().decode())
66
        except ValueError:
67
            raise ValidationError(_('File is not in the expected JSON format.'))
68

  
69

  
57 70
class LoginPasswordAuthenticatorEditForm(forms.ModelForm):
58 71
    class Meta:
59 72
        model = LoginPasswordAuthenticator
src/authentic2/apps/authenticators/manager_urls.py
76 76
            views.journal,
77 77
            name='a2-manager-authenticator-journal',
78 78
        ),
79
        path('authenticators/<int:pk>/export/', views.export_json, name='a2-manager-authenticator-export'),
80
        path('authenticators/import/', views.import_json, name='a2-manager-authenticator-import'),
79 81
        path(
80 82
            'authenticators/order/',
81 83
            views.order,
src/authentic2/apps/authenticators/models.py
18 18
import logging
19 19
import uuid
20 20

  
21
from django.apps import apps
21 22
from django.core.exceptions import ValidationError
22 23
from django.db import models
24
from django.db.models import Max
23 25
from django.shortcuts import render, reverse
24 26
from django.utils.formats import date_format
25 27
from django.utils.text import capfirst
......
28 30

  
29 31
from authentic2 import views
30 32
from authentic2.a2_rbac.models import Role
33
from authentic2.data_transfer import search_ou, search_role
31 34
from authentic2.manager.utils import label_from_role
32 35
from authentic2.utils.evaluate import condition_validator, evaluate_condition
33 36

  
......
36 39
logger = logging.getLogger(__name__)
37 40

  
38 41

  
42
class AuthenticatorImportError(Exception):
43
    pass
44

  
45

  
39 46
class BaseAuthenticator(models.Model):
40 47
    uuid = models.CharField(max_length=255, unique=True, default=uuid.uuid4, editable=False)
41 48
    name = models.CharField(_('Name'), blank=True, max_length=128)
......
76 83
    authenticators = AuthenticatorManager()
77 84

  
78 85
    type = ''
79
    related_models = []
86
    related_models = {}
80 87
    related_object_form_class = None
81 88
    manager_view_template_name = 'authentic2/authenticators/authenticator_detail.html'
82 89
    unique = False
......
142 149
                return False
143 150
        return True
144 151

  
152
    def export_json(self):
153
        data = {
154
            'authenticator_type': '%s.%s' % (self._meta.app_label, self._meta.model_name),
155
        }
156

  
157
        fields = [
158
            f for f in self._meta.get_fields() if not f.is_relation and not f.auto_created and f.editable
159
        ]
160
        data.update({field.name: getattr(self, field.attname) for field in fields})
161

  
162
        data['ou'] = self.ou and self.ou.natural_key_json()
163
        data['related_objects'] = [obj.export_json() for qs in self.related_models.values() for obj in qs]
164

  
165
        return data
166

  
167
    @staticmethod
168
    def import_json(data):
169
        def get_model_from_dict(data, key):
170
            try:
171
                model_name = data.pop(key)
172
            except KeyError:
173
                raise AuthenticatorImportError(_('Missing "%s" key.') % key)
174

  
175
            try:
176
                return apps.get_model(model_name)
177
            except LookupError:
178
                raise AuthenticatorImportError(
179
                    _('Unknown %(key)s: %(value)s.') % {'key': key, 'value': model_name}
180
                )
181
            except ValueError:
182
                raise AuthenticatorImportError(
183
                    _('Invalid %(key)s: %(value)s.') % {'key': key, 'value': model_name}
184
                )
185

  
186
        related_objects = data.pop('related_objects', [])
187

  
188
        ou = data.pop('ou', None)
189
        if ou:
190
            data['ou'] = search_ou(ou)
191
            if not data['ou']:
192
                raise AuthenticatorImportError(_('Organization unit not found: %s.') % ou)
193

  
194
        model = get_model_from_dict(data, 'authenticator_type')
195
        try:
196
            slug = data.pop('slug')
197
        except KeyError:
198
            raise AuthenticatorImportError(_('Missing slug.'))
199
        authenticator, created = model.objects.update_or_create(slug=slug, defaults=data)
200

  
201
        for obj in related_objects:
202
            model = get_model_from_dict(obj, 'object_type')
203
            model.import_json(obj, authenticator)
204

  
205
        if created:
206
            max_order = BaseAuthenticator.objects.aggregate(max=Max('order'))['max'] or 0
207
            authenticator.order = max_order + 1
208
            authenticator.save()
209

  
210
        return authenticator, created
211

  
145 212

  
146 213
class AuthenticatorRelatedObjectBase(models.Model):
147 214
    authenticator = models.ForeignKey(BaseAuthenticator, on_delete=models.CASCADE)
......
160 227
    def verbose_name_plural(self):
161 228
        return self._meta.verbose_name_plural
162 229

  
230
    def export_json(self):
231
        data = {
232
            'object_type': '%s.%s' % (self._meta.app_label, self._meta.model_name),
233
        }
234

  
235
        fields = [
236
            f for f in self._meta.get_fields() if not f.is_relation and not f.auto_created and f.editable
237
        ]
238
        data.update({field.name: getattr(self, field.attname) for field in fields})
239
        return data
240

  
241
    @classmethod
242
    def import_json(cls, data, authenticator):
243
        cls.objects.update_or_create(authenticator=authenticator, **data)
244

  
163 245

  
164 246
class AddRoleAction(AuthenticatorRelatedObjectBase):
165 247
    role = models.ForeignKey(Role, verbose_name=_('Role'), on_delete=models.CASCADE)
......
176 258
    def __str__(self):
177 259
        return label_from_role(self.role)
178 260

  
261
    def export_json(self):
262
        data = super().export_json()
263
        data['role'] = self.role.natural_key_json()
264
        return data
265

  
266
    @classmethod
267
    def import_json(cls, data, authenticator):
268
        try:
269
            role = data.pop('role')
270
        except KeyError:
271
            raise AuthenticatorImportError(_('Missing "role" key in add role action.'))
272

  
273
        data['role'] = search_role(role)
274
        if not data['role']:
275
            raise AuthenticatorImportError(_('Role not found: %s.') % role)
276

  
277
        super().import_json(data, authenticator)
278

  
179 279

  
180 280
class LoginPasswordAuthenticator(BaseAuthenticator):
181 281
    remember_me = models.PositiveIntegerField(
src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_detail.html
11 11
    {% endif %}
12 12
    <a href="{% url 'a2-manager-authenticator-edit' pk=object.pk %}">{% trans "Edit" %}</a>
13 13
    <ul class="extra-actions-menu">
14
      <li><a href="{% url 'a2-manager-authenticator-export' pk=object.pk %}">{% trans "Export" %}</a></li>
14 15
      <li><a href="{% url 'a2-manager-authenticator-journal' pk=object.pk %}">{% trans "Journal" %}</a></li>
15 16
      {% if not object.protected %}
16 17
        <li><a rel="popup" href="{% url 'a2-manager-authenticator-delete' pk=object.pk %}">{% trans "Delete" %}</a></li>
src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticators.html
4 4
{% block appbar %}
5 5
  {{ block.super }}
6 6
  <span class="actions">
7
    <a class="extra-actions-menu-opener"></a>
7 8
    <a href="{% url 'a2-manager-authenticators-order' %}" rel="popup">{% trans "Edit order" %}</a>
8 9
    <a href="{% url 'a2-manager-authenticator-add' %}" rel="popup">{% trans "Add new authenticator" %}</a>
10
    <ul class="extra-actions-menu">
11
      <li><a href="{% url 'a2-manager-authenticator-import' %}" rel="popup">{% trans "Import" %}</a></li>
12
    </ul>
9 13
  </span>
10 14
{% endblock %}
11 15

  
src/authentic2/apps/authenticators/views.py
14 14
# You should have received a copy of the GNU Affero General Public License
15 15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 16

  
17
import datetime
18
import json
19

  
17 20
from django.apps import apps
18 21
from django.contrib import messages
19 22
from django.core.exceptions import PermissionDenied
20 23
from django.forms.models import modelform_factory
21
from django.http import Http404, HttpResponseRedirect
24
from django.http import Http404, HttpResponse, HttpResponseRedirect
22 25
from django.shortcuts import get_object_or_404
23 26
from django.urls import reverse, reverse_lazy
24 27
from django.utils.functional import cached_property
......
27 30
from django.views.generic import CreateView, DeleteView, DetailView, FormView, UpdateView
28 31
from django.views.generic.list import ListView
29 32

  
30
from authentic2.apps.authenticators import forms
31
from authentic2.apps.authenticators.models import BaseAuthenticator
32 33
from authentic2.apps.journal.views import JournalViewWithContext
33 34
from authentic2.manager.journal_views import BaseJournalView
34 35
from authentic2.manager.views import MediaMixin, TitleMixin
35 36

  
37
from . import forms
38
from .models import AuthenticatorImportError, BaseAuthenticator
39

  
36 40

  
37 41
class AuthenticatorsMixin(MediaMixin, TitleMixin):
38 42
    model = BaseAuthenticator
......
190 194
journal = AuthenticatorJournal.as_view()
191 195

  
192 196

  
197
class AuthenticatorExportView(AuthenticatorsMixin, DetailView):
198
    def get(self, request, *args, **kwargs):
199
        authenticator = self.get_object()
200

  
201
        response = HttpResponse(content_type='application/json')
202
        today = datetime.date.today()
203
        attachment = 'attachment; filename="export_{}_{}.json"'.format(
204
            authenticator.slug.replace('-', '_'), today.strftime('%Y%m%d')
205
        )
206
        response['Content-Disposition'] = attachment
207
        json.dump(authenticator.export_json(), response, indent=4)
208
        return response
209

  
210

  
211
export_json = AuthenticatorExportView.as_view()
212

  
213

  
214
class AuthenticatorImportView(AuthenticatorsMixin, FormView):
215
    form_class = forms.AuthenticatorImportForm
216
    template_name = 'authentic2/manager/import_form.html'
217
    title = _('Authenticator Import')
218

  
219
    def form_valid(self, form):
220
        try:
221
            self.authenticator, created = BaseAuthenticator.import_json(
222
                form.cleaned_data['authenticator_json']
223
            )
224
        except AuthenticatorImportError as e:
225
            form.add_error('authenticator_json', e)
226
            return self.form_invalid(form)
227

  
228
        if created:
229
            messages.success(self.request, _('Authenticator has been created.'))
230
        else:
231
            messages.success(self.request, _('Authenticator has been updated.'))
232

  
233
        return super().form_valid(form)
234

  
235
    def get_success_url(self):
236
        return reverse('a2-manager-authenticator-detail', kwargs={'pk': self.authenticator.pk})
237

  
238

  
239
import_json = AuthenticatorImportView.as_view()
240

  
241

  
193 242
class AuthenticatorsOrderView(AuthenticatorsMixin, FormView):
194 243
    template_name = 'authentic2/authenticators/authenticators_order_form.html'
195 244
    title = _('Configure display order')
tests/test_manager_authenticators.py
14 14
# You should have received a copy of the GNU Affero General Public License
15 15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 16

  
17
import json
18

  
17 19
import pytest
18 20
from django import VERSION as DJ_VERSION
19 21
from django.utils.html import escape
22
from webtest import Upload
20 23

  
21 24
from authentic2.a2_rbac.utils import get_default_ou
22
from authentic2.apps.authenticators.models import BaseAuthenticator, LoginPasswordAuthenticator
25
from authentic2.apps.authenticators.models import AddRoleAction, BaseAuthenticator, LoginPasswordAuthenticator
23 26
from authentic2.models import Attribute
24 27
from authentic2_auth_fc.models import FcAuthenticator
25
from authentic2_auth_oidc.models import OIDCProvider
26
from authentic2_auth_saml.models import SAMLAuthenticator
28
from authentic2_auth_oidc.models import OIDCClaimMapping, OIDCProvider
29
from authentic2_auth_saml.models import SAMLAuthenticator, SetAttributeAction
27 30

  
28 31
from .utils import assert_event, login, logout
29 32

  
......
101 104
    assert 'Password' not in resp.text
102 105

  
103 106

  
107
@pytest.mark.freeze_time('2022-04-19 14:00')
108
def test_authenticators_password_export(app, superuser):
109
    resp = login(app, superuser, path='/manage/authenticators/')
110
    assert LoginPasswordAuthenticator.objects.count() == 1
111

  
112
    resp = resp.click('Configure')
113
    resp = resp.click('Export')
114
    assert resp.headers['content-type'] == 'application/json'
115
    assert (
116
        resp.headers['content-disposition']
117
        == 'attachment; filename="export_password_authenticator_20220419.json"'
118
    )
119

  
120
    authenticator_json = json.loads(resp.text)
121
    assert authenticator_json == {
122
        'authenticator_type': 'authenticators.loginpasswordauthenticator',
123
        'button_description': '',
124
        'button_label': 'Login',
125
        'include_ou_selector': False,
126
        'name': '',
127
        'ou': None,
128
        'related_objects': [],
129
        'remember_me': None,
130
        'show_condition': '',
131
        'slug': 'password-authenticator',
132
    }
133

  
134
    resp = app.get('/manage/authenticators/')
135
    resp = resp.click('Import')
136
    authenticator_json['button_description'] = 'test'
137
    resp.form['authenticator_json'] = Upload(
138
        'export.json', json.dumps(authenticator_json).encode(), 'application/json'
139
    )
140
    resp = resp.form.submit()
141
    assert LoginPasswordAuthenticator.objects.count() == 1
142
    assert LoginPasswordAuthenticator.objects.get().button_description == 'test'
143

  
144

  
104 145
@pytest.mark.freeze_time('2022-04-19 14:00')
105 146
def test_authenticators_oidc(app, superuser, ou1, ou2):
106 147
    resp = login(app, superuser, path='/manage/authenticators/')
......
233 274
    assert 'role_ou1' in resp.text
234 275

  
235 276

  
277
def test_authenticators_oidc_export(app, superuser, simple_role):
278
    authenticator = OIDCProvider.objects.create(slug='idp1', order=42, ou=get_default_ou(), enabled=True)
279
    OIDCClaimMapping.objects.create(authenticator=authenticator, claim='test', attribute='hop')
280
    AddRoleAction.objects.create(authenticator=authenticator, role=simple_role)
281

  
282
    resp = login(app, superuser, path=authenticator.get_absolute_url())
283
    export_resp = resp.click('Export')
284

  
285
    resp = app.get('/manage/authenticators/import/')
286
    resp.form['authenticator_json'] = Upload('export.json', export_resp.body, 'application/json')
287
    resp = resp.form.submit()
288
    assert '/authenticators/%s/' % authenticator.pk in resp.location
289

  
290
    resp = resp.follow()
291
    assert 'Authenticator has been updated.' in resp.text
292
    assert OIDCProvider.objects.count() == 1
293
    assert OIDCClaimMapping.objects.count() == 1
294
    assert AddRoleAction.objects.count() == 1
295

  
296
    OIDCProvider.objects.all().delete()
297
    OIDCClaimMapping.objects.all().delete()
298
    AddRoleAction.objects.all().delete()
299

  
300
    resp = app.get('/manage/authenticators/import/')
301
    resp.form['authenticator_json'] = Upload('export.json', export_resp.body, 'application/json')
302
    resp = resp.form.submit().follow()
303
    assert 'Authenticator has been created.' in resp.text
304

  
305
    authenticator = OIDCProvider.objects.get()
306
    assert authenticator.slug == 'idp1'
307
    assert authenticator.order == 1
308
    assert authenticator.ou == get_default_ou()
309
    assert authenticator.enabled is False
310
    assert OIDCClaimMapping.objects.filter(
311
        authenticator=authenticator, claim='test', attribute='hop'
312
    ).exists()
313
    assert AddRoleAction.objects.filter(authenticator=authenticator, role=simple_role).exists()
314

  
315

  
316
def test_authenticators_oidc_import_errors(app, superuser, simple_role):
317
    resp = login(app, superuser, path='/manage/authenticators/import/')
318
    resp.form['authenticator_json'] = Upload('export.json', b'not-json', 'application/json')
319
    resp = resp.form.submit()
320
    assert 'File is not in the expected JSON format.' in resp.text
321

  
322
    resp.form['authenticator_json'] = Upload('export.json', b'{}', 'application/json')
323
    resp = resp.form.submit()
324
    assert escape('Missing "authenticator_type" key.') in resp.text
325

  
326
    resp.form['authenticator_json'] = Upload(
327
        'export.json', b'{"authenticator_type": "xxx"}', 'application/json'
328
    )
329
    resp = resp.form.submit()
330
    assert 'Invalid authenticator_type: xxx.' in resp.text
331

  
332
    resp.form['authenticator_json'] = Upload(
333
        'export.json', b'{"authenticator_type": "x.y"}', 'application/json'
334
    )
335
    resp = resp.form.submit()
336
    assert 'Unknown authenticator_type: x.y.' in resp.text
337

  
338
    authenticator = OIDCProvider.objects.create(slug='idp1', order=42, ou=get_default_ou(), enabled=True)
339
    AddRoleAction.objects.create(authenticator=authenticator, role=simple_role)
340

  
341
    export_resp = app.get('/manage/authenticators/%s/export/' % authenticator.pk)
342

  
343
    export = json.loads(export_resp.text)
344
    del export['slug']
345
    resp.form['authenticator_json'] = Upload('export.json', json.dumps(export).encode(), 'application/json')
346
    resp = resp.form.submit()
347
    assert 'Missing slug.' in resp.text
348

  
349
    export = json.loads(export_resp.text)
350
    export['ou'] = {'slug': 'xxx'}
351
    resp.form['authenticator_json'] = Upload('export.json', json.dumps(export).encode(), 'application/json')
352
    resp = resp.form.submit()
353
    assert escape("Organization unit not found: {'slug': 'xxx'}.") in resp.text
354

  
355
    export = json.loads(export_resp.text)
356
    del export['related_objects'][0]['object_type']
357
    resp.form['authenticator_json'] = Upload('export.json', json.dumps(export).encode(), 'application/json')
358
    resp = resp.form.submit()
359
    assert escape('Missing "object_type" key.') in resp.text
360

  
361
    export = json.loads(export_resp.text)
362
    del export['related_objects'][0]['role']
363
    resp.form['authenticator_json'] = Upload('export.json', json.dumps(export).encode(), 'application/json')
364
    resp = resp.form.submit()
365
    assert escape('Missing "role" key in add role action.') in resp.text
366

  
367
    export = json.loads(export_resp.text)
368
    export['related_objects'][0]['role'] = {'slug': 'xxx'}
369
    resp.form['authenticator_json'] = Upload('export.json', json.dumps(export).encode(), 'application/json')
370
    resp = resp.form.submit()
371
    assert escape("Role not found: {'slug': 'xxx'}.") in resp.text
372

  
373

  
236 374
def test_authenticators_fc(app, superuser):
237 375
    resp = login(app, superuser, path='/manage/authenticators/')
238 376

  
......
423 561
    assert 'role_ou2' in resp.text
424 562

  
425 563

  
564
def test_authenticators_saml_export(app, superuser, simple_role):
565
    authenticator = SAMLAuthenticator.objects.create(metadata='meta1.xml', slug='idp1')
566
    SetAttributeAction.objects.create(authenticator=authenticator, user_field='test', saml_attribute='hop')
567
    AddRoleAction.objects.create(authenticator=authenticator, role=simple_role)
568

  
569
    resp = login(app, superuser, path=authenticator.get_absolute_url())
570
    export_resp = resp.click('Export')
571

  
572
    SAMLAuthenticator.objects.all().delete()
573
    SetAttributeAction.objects.all().delete()
574
    AddRoleAction.objects.all().delete()
575

  
576
    resp = app.get('/manage/authenticators/import/')
577
    resp.form['authenticator_json'] = Upload('export.json', export_resp.body, 'application/json')
578
    resp = resp.form.submit().follow()
579

  
580
    authenticator = SAMLAuthenticator.objects.get()
581
    assert authenticator.slug == 'idp1'
582
    assert authenticator.metadata == 'meta1.xml'
583
    assert SetAttributeAction.objects.filter(
584
        authenticator=authenticator, user_field='test', saml_attribute='hop'
585
    ).exists()
586
    assert AddRoleAction.objects.filter(authenticator=authenticator, role=simple_role).exists()
587

  
588

  
426 589
def test_authenticators_order(app, superuser):
427 590
    resp = login(app, superuser, path='/manage/authenticators/')
428 591

  
429
-