0001-authenticators-log-modifications-to-journal-65358.patch
src/authentic2/apps/authenticators/journal_event_types.py | ||
---|---|---|
1 |
# authentic2 - versatile identity manager |
|
2 |
# Copyright (C) 2010-2022 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 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 Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
from django.utils.translation import gettext_lazy as _ |
|
18 | ||
19 |
from authentic2.apps.journal.models import EventTypeDefinition |
|
20 |
from authentic2.apps.journal.utils import form_to_old_new |
|
21 | ||
22 |
from .models import BaseAuthenticator |
|
23 | ||
24 | ||
25 |
class AuthenticatorCreation(EventTypeDefinition): |
|
26 |
name = 'authenticator.creation' |
|
27 |
label = _('authenticator creation') |
|
28 | ||
29 |
@classmethod |
|
30 |
def record(cls, *, user, session, authenticator): |
|
31 |
super().record(user=user, session=session, references=[authenticator]) |
|
32 | ||
33 |
@classmethod |
|
34 |
def get_message(cls, event, context): |
|
35 |
(authenticator,) = event.get_typed_references(BaseAuthenticator) |
|
36 |
if context != authenticator: |
|
37 |
return _('creation of authenticator "%s"') % authenticator |
|
38 |
else: |
|
39 |
return _('creation') |
|
40 | ||
41 | ||
42 |
class AuthenticatorEdit(EventTypeDefinition): |
|
43 |
name = 'authenticator.edit' |
|
44 |
label = _('authenticator edit') |
|
45 | ||
46 |
@classmethod |
|
47 |
def record(cls, *, user, session, form): |
|
48 |
super().record(user=user, session=session, references=[form.instance], data=form_to_old_new(form)) |
|
49 | ||
50 |
@classmethod |
|
51 |
def get_message(cls, event, context): |
|
52 |
(authenticator,) = event.get_typed_references(BaseAuthenticator) |
|
53 |
new = event.get_data('new') or {} |
|
54 |
edited_attributes = ', '.join(new) or '' |
|
55 |
if context != authenticator: |
|
56 |
return _('edit of authenticator "{authenticator}" ({change})').format( |
|
57 |
authenticator=authenticator, change=edited_attributes |
|
58 |
) |
|
59 |
else: |
|
60 |
return _('edit ({change})').format(change=edited_attributes) |
|
61 | ||
62 | ||
63 |
class AuthenticatorEnable(EventTypeDefinition): |
|
64 |
name = 'authenticator.enable' |
|
65 |
label = _('authenticator enable') |
|
66 | ||
67 |
@classmethod |
|
68 |
def record(cls, *, user, session, authenticator): |
|
69 |
super().record(user=user, session=session, references=[authenticator]) |
|
70 | ||
71 |
@classmethod |
|
72 |
def get_message(cls, event, context): |
|
73 |
(authenticator,) = event.get_typed_references(BaseAuthenticator) |
|
74 |
if context != authenticator: |
|
75 |
return _('enable of authenticator "%s"') % authenticator |
|
76 |
else: |
|
77 |
return _('enable') |
|
78 | ||
79 | ||
80 |
class AuthenticatorDisable(EventTypeDefinition): |
|
81 |
name = 'authenticator.disable' |
|
82 |
label = _('authenticator disable') |
|
83 | ||
84 |
@classmethod |
|
85 |
def record(cls, *, user, session, authenticator): |
|
86 |
super().record(user=user, session=session, references=[authenticator]) |
|
87 | ||
88 |
@classmethod |
|
89 |
def get_message(cls, event, context): |
|
90 |
(authenticator,) = event.get_typed_references(BaseAuthenticator) |
|
91 |
if context != authenticator: |
|
92 |
return _('disable of authenticator "%s"') % authenticator |
|
93 |
else: |
|
94 |
return _('disable') |
|
95 | ||
96 | ||
97 |
class AuthenticatorDeletion(EventTypeDefinition): |
|
98 |
name = 'authenticator.deletion' |
|
99 |
label = _('authenticator deletion') |
|
100 | ||
101 |
@classmethod |
|
102 |
def record(cls, *, user, session, authenticator): |
|
103 |
super().record(user=user, session=session, references=[authenticator]) |
|
104 | ||
105 |
@classmethod |
|
106 |
def get_message(cls, event, context): |
|
107 |
(authenticator,) = event.get_typed_references(BaseAuthenticator) |
|
108 |
return _('deletion of authenticator "%s"') % authenticator |
src/authentic2/apps/authenticators/manager_urls.py | ||
---|---|---|
71 | 71 |
views.toggle, |
72 | 72 |
name='a2-manager-authenticator-toggle', |
73 | 73 |
), |
74 |
path( |
|
75 |
'authenticators/<int:pk>/journal/', |
|
76 |
views.journal, |
|
77 |
name='a2-manager-authenticator-journal', |
|
78 |
), |
|
74 | 79 |
], |
75 | 80 |
) |
src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_detail.html | ||
---|---|---|
4 | 4 |
{% block appbar %} |
5 | 5 |
{{ block.super }} |
6 | 6 |
<span class="actions"> |
7 |
{% if not object.internal %} |
|
8 | 7 |
<a class="extra-actions-menu-opener"></a> |
9 |
{% endif %} |
|
10 | 8 | |
11 | 9 |
{% if object.has_valid_configuration and not object.internal %} |
12 | 10 |
<a href="{% url 'a2-manager-authenticator-toggle' pk=object.pk %}">{{ object.enabled|yesno:_("Disable,Enable") }}</a> |
13 | 11 |
{% endif %} |
14 | 12 |
<a href="{% url 'a2-manager-authenticator-edit' pk=object.pk %}">{% trans "Edit" %}</a> |
15 | 13 |
<ul class="extra-actions-menu"> |
14 |
<li><a href="{% url 'a2-manager-authenticator-journal' pk=object.pk %}">{% trans "Journal" %}</a></li> |
|
16 | 15 |
{% if not object.internal %} |
17 | 16 |
<li><a rel="popup" href="{% url 'a2-manager-authenticator-delete' pk=object.pk %}">{% trans "Delete" %}</a></li> |
18 | 17 |
{% endif %} |
src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_journal.html | ||
---|---|---|
1 |
{% extends "authentic2/manager/journal.html" %} |
|
2 |
{% load i18n %} |
|
3 | ||
4 |
{% block breadcrumb-before-title %} |
|
5 |
<a href="{% url 'a2-manager-authenticators' %}">{% trans "Authenticators" %}</a> |
|
6 |
<a href="{% url 'a2-manager-authenticator-detail' pk=object.pk %}">{{ object }}</a> |
|
7 |
{% endblock %} |
src/authentic2/apps/authenticators/views.py | ||
---|---|---|
17 | 17 |
from django.contrib import messages |
18 | 18 |
from django.core.exceptions import PermissionDenied |
19 | 19 |
from django.http import HttpResponseRedirect |
20 |
from django.shortcuts import get_object_or_404 |
|
20 | 21 |
from django.urls import reverse, reverse_lazy |
22 |
from django.utils.functional import cached_property |
|
21 | 23 |
from django.utils.translation import ugettext as _ |
22 | 24 |
from django.views.generic import CreateView, DeleteView, DetailView, UpdateView |
23 | 25 |
from django.views.generic.list import ListView |
24 | 26 | |
25 | 27 |
from authentic2.apps.authenticators import forms |
26 | 28 |
from authentic2.apps.authenticators.models import BaseAuthenticator |
29 |
from authentic2.apps.journal.views import JournalViewWithContext |
|
30 |
from authentic2.manager.journal_views import BaseJournalView |
|
27 | 31 |
from authentic2.manager.views import MediaMixin, TitleMixin |
28 | 32 | |
29 | 33 | |
... | ... | |
49 | 53 |
def get_success_url(self): |
50 | 54 |
return reverse('a2-manager-authenticator-edit', kwargs={'pk': self.object.pk}) |
51 | 55 | |
56 |
def form_valid(self, form): |
|
57 |
resp = super().form_valid(form) |
|
58 |
self.request.journal.record('authenticator.creation', authenticator=form.instance) |
|
59 |
return resp |
|
60 | ||
52 | 61 | |
53 | 62 |
add = AuthenticatorAddView.as_view() |
54 | 63 | |
... | ... | |
73 | 82 |
def get_form_class(self): |
74 | 83 |
return self.object.manager_form_class |
75 | 84 | |
85 |
def form_valid(self, form): |
|
86 |
self.request.journal.record('authenticator.edit', form=form) |
|
87 |
return super().form_valid(form) |
|
88 | ||
76 | 89 | |
77 | 90 |
edit = AuthenticatorEditView.as_view() |
78 | 91 | |
... | ... | |
88 | 101 |
raise PermissionDenied |
89 | 102 |
return super().dispatch(*args, **kwargs) |
90 | 103 | |
104 |
def delete(self, *args, **kwargs): |
|
105 |
self.request.journal.record('authenticator.deletion', authenticator=self.get_object()) |
|
106 |
return super().delete(*args, **kwargs) |
|
107 | ||
91 | 108 | |
92 | 109 |
delete = AuthenticatorDeleteView.as_view() |
93 | 110 | |
... | ... | |
105 | 122 |
if self.authenticator.enabled: |
106 | 123 |
self.authenticator.enabled = False |
107 | 124 |
self.authenticator.save() |
125 |
self.request.journal.record('authenticator.disable', authenticator=self.authenticator) |
|
108 | 126 |
message = _('Authenticator has been disabled.') |
109 | 127 |
else: |
110 | 128 |
self.authenticator.enabled = True |
111 | 129 |
self.authenticator.save() |
130 |
self.request.journal.record('authenticator.enable', authenticator=self.authenticator) |
|
112 | 131 |
message = _('Authenticator has been enabled.') |
113 | 132 | |
114 | 133 |
messages.info(self.request, message) |
... | ... | |
116 | 135 | |
117 | 136 | |
118 | 137 |
toggle = AuthenticatorToggleView.as_view() |
138 | ||
139 | ||
140 |
class AuthenticatorJournal(JournalViewWithContext, BaseJournalView): |
|
141 |
template_name = 'authentic2/authenticators/authenticator_journal.html' |
|
142 |
title = _('Journal') |
|
143 | ||
144 |
@cached_property |
|
145 |
def context(self): |
|
146 |
return get_object_or_404(BaseAuthenticator.authenticators.all(), pk=self.kwargs['pk']) |
|
147 | ||
148 |
def get_context_data(self, **kwargs): |
|
149 |
ctx = super().get_context_data(**kwargs) |
|
150 |
ctx['object'] = self.context |
|
151 |
ctx['object_name'] = str(self.context) |
|
152 |
return ctx |
|
153 | ||
154 | ||
155 |
journal = AuthenticatorJournal.as_view() |
tests/test_manager_authenticators.py | ||
---|---|---|
20 | 20 |
from authentic2.apps.authenticators.models import LoginPasswordAuthenticator |
21 | 21 |
from authentic2_auth_oidc.models import OIDCProvider |
22 | 22 | |
23 |
from .utils import login, logout |
|
23 |
from .utils import assert_event, login, logout
|
|
24 | 24 | |
25 | 25 | |
26 | 26 |
def test_authenticators_authorization(app, simple_user, superuser): |
... | ... | |
45 | 45 |
assert 'Click "Edit" to change configuration.' in resp.text |
46 | 46 |
# cannot delete password authenticator |
47 | 47 |
assert 'Delete' not in resp.text |
48 |
assert 'extra-actions-menu-opener' not in resp.text |
|
49 | 48 |
assert 'configuration is not complete' not in resp.text |
50 | 49 |
app.get('/manage/authenticators/%s/delete/' % authenticator.pk, status=403) |
51 | 50 | |
... | ... | |
77 | 76 |
"Show condition: 'backoffice' in login_hint or remotre_addr == '1.2.3.4'" |
78 | 77 |
in resp.text |
79 | 78 |
) |
79 |
assert_event('authenticator.edit', user=superuser, session=app.session) |
|
80 | 80 | |
81 | 81 |
resp = resp.click('Edit') |
82 | 82 |
resp.form['show_condition'] = "remote_addr in dnsbl('ddns.entrouvert.org')" |
... | ... | |
87 | 87 |
assert 'Disable' not in resp.text |
88 | 88 |
app.get('/manage/authenticators/%s/toggle/' % authenticator.pk, status=403) |
89 | 89 | |
90 |
resp = resp.click('Journal') |
|
91 |
assert resp.text.count('edit (show_condition)') == 2 |
|
92 | ||
90 | 93 |
# cannot add another password authenticator |
91 | 94 |
resp = app.get('/manage/authenticators/add/') |
92 | 95 |
assert 'Password' not in resp.text |
... | ... | |
102 | 105 |
resp.form['ou'] = ou1.pk |
103 | 106 |
resp = resp.form.submit() |
104 | 107 |
assert '/edit/' in resp.location |
108 |
assert_event('authenticator.creation', user=superuser, session=app.session) |
|
105 | 109 | |
106 | 110 |
provider = OIDCProvider.objects.filter(slug='test').get() |
107 | 111 |
resp = app.get(provider.get_absolute_url()) |
... | ... | |
124 | 128 |
resp.form['userinfo_endpoint'] = 'https://oidc.example.com/user_info' |
125 | 129 |
resp.form['idtoken_algo'] = 2 |
126 | 130 |
resp = resp.form.submit().follow() |
131 |
assert_event('authenticator.edit', user=superuser, session=app.session) |
|
127 | 132 | |
128 | 133 |
assert 'Issuer: https://oidc.example.com' in resp.text |
129 | 134 |
assert 'Scopes: profile email' in resp.text |
... | ... | |
136 | 141 |
resp = resp.click('Configure', index=1) |
137 | 142 |
resp = resp.click('Enable').follow() |
138 | 143 |
assert 'Authenticator has been enabled.' in resp.text |
144 |
assert_event('authenticator.enable', user=superuser, session=app.session) |
|
145 | ||
146 |
resp = resp.click('Journal') |
|
147 |
assert 'enable' in resp.text |
|
148 |
assert ( |
|
149 |
'edit (issuer, scopes, strategy, idtoken_algo, token_endpoint, userinfo_endpoint, authorization_endpoint)' |
|
150 |
in resp.text |
|
151 |
) |
|
152 |
assert 'creation' in resp.text |
|
139 | 153 | |
140 | 154 |
resp = app.get('/manage/authenticators/') |
141 | 155 |
assert 'class="section disabled"' not in resp.text |
... | ... | |
162 | 176 |
resp = resp.click('Configure', index=1) |
163 | 177 |
resp = resp.click('Disable').follow() |
164 | 178 |
assert 'Authenticator has been disabled.' in resp.text |
179 |
assert_event('authenticator.disable', user=superuser, session=app.session) |
|
165 | 180 | |
166 | 181 |
resp = app.get('/manage/authenticators/') |
167 | 182 |
assert 'class="section disabled"' in resp.text |
... | ... | |
170 | 185 |
resp = resp.click('Delete') |
171 | 186 |
resp = resp.form.submit().follow() |
172 | 187 |
assert not OIDCProvider.objects.filter(slug='test').exists() |
188 |
assert_event('authenticator.deletion', user=superuser, session=app.session) |
tests/test_manager_journal.py | ||
---|---|---|
23 | 23 | |
24 | 24 |
from authentic2.a2_rbac.models import Role |
25 | 25 |
from authentic2.a2_rbac.utils import get_default_ou |
26 |
from authentic2.apps.authenticators.models import LoginPasswordAuthenticator |
|
26 | 27 |
from authentic2.apps.journal.models import Event, _registry |
27 | 28 |
from authentic2.custom_user.models import Profile, ProfileType, User |
28 | 29 |
from authentic2.journal import journal |
... | ... | |
56 | 57 |
role_user = Role.objects.create(name="role1", ou=ou) |
57 | 58 |
role_agent = Role.objects.create(name="role2", ou=ou) |
58 | 59 |
service = Service.objects.create(name="service") |
60 |
authenticator = LoginPasswordAuthenticator.objects.create(slug='test') |
|
59 | 61 | |
60 | 62 |
class EventFactory: |
61 | 63 |
date = make_aware(datetime.datetime(2020, 1, 1)) |
... | ... | |
289 | 291 |
) |
290 | 292 |
make('user.notification.inactivity', user=user, days_of_inactivity=120, days_to_deletion=20) |
291 | 293 |
make('user.deletion.inactivity', user=user, days_of_inactivity=140) |
294 |
make('authenticator.creation', user=agent, session=session2, authenticator=authenticator) |
|
295 |
authenticator_edit_form = mock.Mock(spec=['instance', 'initial', 'changed_data', 'cleaned_data']) |
|
296 |
authenticator_edit_form.instance = authenticator |
|
297 |
authenticator_edit_form.initial = {'name': 'old'} |
|
298 |
authenticator_edit_form.changed_data = ['name'] |
|
299 |
authenticator_edit_form.cleaned_data = {'name': 'new'} |
|
300 |
make('authenticator.edit', user=agent, session=session2, form=authenticator_edit_form) |
|
301 |
make('authenticator.enable', user=agent, session=session2, authenticator=authenticator) |
|
302 |
make('authenticator.disable', user=agent, session=session2, authenticator=authenticator) |
|
303 |
make('authenticator.deletion', user=agent, session=session2, authenticator=authenticator) |
|
292 | 304 | |
293 | 305 |
# verify we created at least one event for each type |
294 | 306 |
assert set(Event.objects.values_list("type__name", flat=True)) == set(_registry) |
... | ... | |
650 | 662 |
'type': 'user.deletion.inactivity', |
651 | 663 |
'user': 'Johnny doe', |
652 | 664 |
}, |
665 |
{ |
|
666 |
'message': 'creation of authenticator "Password"', |
|
667 |
'timestamp': 'Jan. 3, 2020, 3 a.m.', |
|
668 |
'type': 'authenticator.creation', |
|
669 |
'user': 'agent', |
|
670 |
}, |
|
671 |
{ |
|
672 |
'message': 'edit of authenticator "Password" (name)', |
|
673 |
'timestamp': 'Jan. 3, 2020, 4 a.m.', |
|
674 |
'type': 'authenticator.edit', |
|
675 |
'user': 'agent', |
|
676 |
}, |
|
677 |
{ |
|
678 |
'message': 'enable of authenticator "Password"', |
|
679 |
'timestamp': 'Jan. 3, 2020, 5 a.m.', |
|
680 |
'type': 'authenticator.enable', |
|
681 |
'user': 'agent', |
|
682 |
}, |
|
683 |
{ |
|
684 |
'message': 'disable of authenticator "Password"', |
|
685 |
'timestamp': 'Jan. 3, 2020, 6 a.m.', |
|
686 |
'type': 'authenticator.disable', |
|
687 |
'user': 'agent', |
|
688 |
}, |
|
689 |
{ |
|
690 |
'message': 'deletion of authenticator "Password"', |
|
691 |
'timestamp': 'Jan. 3, 2020, 7 a.m.', |
|
692 |
'type': 'authenticator.deletion', |
|
693 |
'user': 'agent', |
|
694 |
}, |
|
653 | 695 |
] |
654 | 696 | |
655 | 697 |
agent_page = response.click('agent', index=1) |
656 |
- |