Projet

Général

Profil

0002-misc-integration-of-journal-authentic-views-47155.patch

Benjamin Dauvergne, 14 octobre 2020 13:33

Télécharger (37,3 ko)

Voir les différences:

Subject: [PATCH 2/3] misc: integration of journal authentic views (#47155)

 src/authentic2/authenticators.py      |   4 +
 src/authentic2/journal.py             |  37 ++++
 src/authentic2/journal_event_types.py | 266 ++++++++++++++++++++++++++
 src/authentic2/middleware.py          |  17 +-
 src/authentic2/settings.py            |   1 +
 src/authentic2/utils/__init__.py      |   4 +
 src/authentic2/utils/service.py       |  14 +-
 src/authentic2/views.py               |  18 +-
 src/authentic2_idp_oidc/views.py      |   8 +
 tests/conftest.py                     |   3 +-
 tests/test_all.py                     |   5 +-
 tests/test_idp_oidc.py                |   7 +
 tests/test_login.py                   |  20 +-
 tests/test_password_reset.py          |  18 +-
 tests/test_registration.py            |   6 +-
 tests/test_utils.py                   |   2 +
 tests/test_views.py                   |  25 ++-
 tests/utils.py                        |  54 +++++-
 18 files changed, 470 insertions(+), 39 deletions(-)
 create mode 100644 src/authentic2/journal.py
 create mode 100644 src/authentic2/journal_event_types.py
src/authentic2/authenticators.py
133 133
                    utils.prepend_remember_cookie(request, response, 'preferred-ous', form.cleaned_data['ou'].pk)
134 134

  
135 135
                return response
136
            else:
137
                username = form.cleaned_data.get('username', '').strip()
138
                if username:
139
                    request.journal.record('user.login.failure', username=username)
136 140
        context['form'] = form
137 141
        return render(request, 'authentic2/login_password_form.html', context)
138 142

  
src/authentic2/journal.py
1
# authentic2 - versatile identity manager
2
# Copyright (C) 2010-2020 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 authentic2.utils.service import get_service_from_request
18

  
19
from authentic2.apps.journal.journal import Journal
20

  
21

  
22
class Journal(Journal):
23
    def __init__(self, **kwargs):
24
        self._service = kwargs.pop('service', None)
25
        super().__init__(**kwargs)
26

  
27
    @property
28
    def service(self):
29
        return self._service or (get_service_from_request(self.request) if self.request else None)
30

  
31
    def massage_kwargs(self, record_parameters, kwargs):
32
        if 'service' not in kwargs and 'service' in record_parameters:
33
            kwargs['service'] = self.service
34
        return super().massage_kwargs(record_parameters, kwargs)
35

  
36

  
37
journal = Journal()
src/authentic2/journal_event_types.py
1
# authentic2 - versatile identity manager
2
# Copyright (C) 2010-2020 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 ugettext_lazy as _
18

  
19
from authentic2.custom_user.models import get_attributes_map
20
from authentic2.apps.journal.models import EventTypeDefinition
21
from authentic2.apps.journal.utils import form_to_old_new
22
from authentic2.custom_user.models import User
23

  
24
from . import models
25

  
26

  
27
class EventTypeWithService(EventTypeDefinition):
28
    @classmethod
29
    def record(cls, user=None, service=None, session=None, references=None, data=None):
30
        if service:
31
            if not data:
32
                data = {}
33
            data['service_name'] = str(service)
34
            if not references:
35
                references = []
36
            references = [service] + references
37
        super().record(user=user, session=session, references=references, data=data)
38

  
39
    @classmethod
40
    def get_service_name(self, event):
41
        service, = event.get_typed_references(models.Service)
42
        if service is not None:
43
            return str(service)
44
        if 'service_name' in event.data:
45
            return event.data['service_name']
46
        return ''
47

  
48

  
49
def login_method_label(how):
50
    if how.startswith('password'):
51
        return _('password')
52
    elif how == 'fc':
53
        return _('FranceConnect')
54
    elif how == 'saml':
55
        return _('SAML')
56
    elif how == 'oidc':
57
        return _('OpenIDConnect')
58
    elif how:
59
        return how
60
    else:
61
        return _('none')
62

  
63

  
64
def get_attributes_label(attributes_new_values):
65
    # FIXME: attributes cache should be factorized at the level of the AttributeManager as done on ContentType,
66
    # main difference is that attributes are editable objects
67
    attributes_map = get_attributes_map()
68
    for name in attributes_new_values:
69
        if name in ('email', 'first_name', 'last_name'):
70
            yield str(User._meta.get_field(name).verbose_name)
71
        else:
72
            if name in attributes_map:
73
                yield attributes_map[name].label
74
            else:
75
                yield name
76

  
77

  
78
class UserLogin(EventTypeWithService):
79
    name = 'user.login'
80
    label = _('login')
81

  
82
    @classmethod
83
    def record(cls, user, session, service, how):
84
        super().record(user=user, session=session, service=service, data={'how': how})
85

  
86
    @classmethod
87
    def get_message(cls, event, context):
88
        how = event.get_data('how')
89
        return _('login using {method}').format(method=login_method_label(how))
90

  
91

  
92
class UserLoginFailure(EventTypeWithService):
93
    name = 'user.login.failure'
94
    label = _('login failure')
95

  
96
    @classmethod
97
    def record(cls, service, username):
98
        super().record(service=service, data={'username': username})
99

  
100
    @classmethod
101
    def get_message(cls, event, context):
102
        username = event.get_data('username')
103
        return _('login failure with username "{username}"').format(username=username)
104

  
105

  
106
class UserRegistrationRequest(EventTypeDefinition):
107
    name = 'user.registration.request'
108
    label = _('registration request')
109

  
110
    @classmethod
111
    def record(cls, email):
112
        super().record(data={'email': email.lower()})
113

  
114
    @classmethod
115
    def get_message(cls, event, context):
116
        email = event.get_data('email')
117
        return _('registration request with email "%s"') % email
118

  
119

  
120
class UserRegistration(EventTypeWithService):
121
    name = 'user.registration'
122
    label = _('registration')
123

  
124
    @classmethod
125
    def record(cls, user, session, service, how):
126
        super().record(user=user, session=session, service=service, data={'how': how})
127

  
128
    @classmethod
129
    def get_message(cls, event, context):
130
        how = event.get_data('how')
131
        return _('registration using {method}').format(method=login_method_label(how))
132

  
133

  
134
class UserLogout(EventTypeWithService):
135
    name = 'user.logout'
136
    label = _('logout')
137

  
138
    @classmethod
139
    def record(cls, user, session, service):
140
        super().record(user=user, session=session, service=service)
141

  
142
    @classmethod
143
    def get_message(cls, event, context):
144
        return _('logout')
145

  
146

  
147
class UserRequestPasswordReset(EventTypeDefinition):
148
    name = 'user.password.reset.request'
149
    label = _('password reset request')
150

  
151
    @classmethod
152
    def record(cls, user, email):
153
        super().record(user=user, data={'email': email.lower()})
154

  
155
    @classmethod
156
    def get_message(cls, event, context):
157
        email = event.get_data('email')
158
        if email:
159
            return _('password reset request with email "%s"') % email
160
        return super().get_message(event, context)
161

  
162

  
163
class UserResetPassword(EventTypeDefinition):
164
    name = 'user.password.reset'
165
    label = _('password reset')
166

  
167
    @classmethod
168
    def record(cls, user, session):
169
        super().record(user=user, session=session)
170

  
171

  
172
class UserResetPasswordFailure(EventTypeDefinition):
173
    name = 'user.password.reset.failure'
174
    label = _('password reset failure')
175

  
176
    @classmethod
177
    def record(cls, email):
178
        super().record(data={'email': email})
179

  
180
    @classmethod
181
    def get_message(cls, event, context):
182
        email = event.get_data('email')
183
        if email:
184
            return _('password reset failure with email "%s"') % email
185
        return super().get_message(event, context)
186

  
187

  
188
class UserChangePassword(EventTypeWithService):
189
    name = 'user.password.change'
190
    label = _('password change')
191

  
192
    @classmethod
193
    def record(cls, user, session, service):
194
        super().record(user=user, session=session, service=service)
195

  
196

  
197
class UserEdit(EventTypeWithService):
198
    name = 'user.profile.edit'
199
    label = _('profile edit')
200

  
201
    @classmethod
202
    def record(cls, user, session, service, form):
203
        data = form_to_old_new(form)
204
        super().record(user=user, session=session, service=service, data=data)
205

  
206
    @classmethod
207
    def get_message(cls, event, context):
208
        new = event.get_data('new')
209
        if new:
210
            edited_attributes = ', '.join(get_attributes_label(new))
211
            return _('profile edit (%s)') % edited_attributes
212
        return super().get_message(event, context)
213

  
214

  
215
class UserDeletion(EventTypeWithService):
216
    name = 'user.deletion'
217
    label = _('deletion')
218

  
219
    @classmethod
220
    def record(cls, user, session, service):
221
        super().record(user=user, session=session, service=service)
222

  
223

  
224
class UserServiceSSO(EventTypeWithService):
225
    name = 'user.service.sso'
226
    label = _('service single sign on')
227

  
228
    @classmethod
229
    def record(cls, user, session, service, how):
230
        super().record(user=user, session=session, service=service, data={'how': how})
231

  
232
    @classmethod
233
    def get_message(cls, event, context):
234
        service_name = cls.get_service_name(event)
235
        return _('service single sign on with "{service}"').format(
236
            service=service_name)
237

  
238

  
239
class UserServiceSSOAuthorization(EventTypeWithService):
240
    name = 'user.service.sso.authorization'
241
    label = _('consentment to single sign on')
242

  
243
    @classmethod
244
    def record(cls, user, session, service, **kwargs):
245
        super().record(user=user, session=session, service=service, data=kwargs)
246

  
247
    @classmethod
248
    def get_message(cls, event, context):
249
        service_name = cls.get_service_name(event)
250
        return _('authorization of single sign on with "{service}"').format(
251
            service=service_name)
252

  
253

  
254
class UserServiceSSOUnauthorization(EventTypeWithService):
255
    name = 'user.service.sso.unauthorization'
256
    label = _('remove consentment to single sign on')
257

  
258
    @classmethod
259
    def record(cls, user, session, service):
260
        super().record(user=user, session=session, service=service)
261

  
262
    @classmethod
263
    def get_message(cls, event, context):
264
        service_name = cls.get_service_name(event)
265
        return _('unauthorization of single sign on with "{service}"').format(
266
            service=service_name)
src/authentic2/middleware.py
27 27
from django.contrib import messages
28 28
from django.utils.deprecation import MiddlewareMixin
29 29
from django.utils.encoding import force_text
30
from django.utils.functional import SimpleLazyObject
30 31
from django.utils.translation import ugettext as _
31 32
from django.utils.six.moves.urllib import parse as urlparse
32 33
from django.shortcuts import render
33 34

  
34 35
from . import app_settings, utils, plugins
35
from .utils.service import get_service_from_request
36
from .utils.service import get_service_from_request, get_service_from_session
36 37

  
37 38

  
38 39
class CollectIPMiddleware(MiddlewareMixin):
......
208 209
        self.get_response = get_response
209 210

  
210 211
    def __call__(self, request):
211
        service = None
212

  
213 212
        service = get_service_from_request(request)
214 213
        if service:
215 214
            request.session['service_pk'] = service.pk
216

  
215
        request.service = SimpleLazyObject(lambda: get_service_from_session(request))
217 216
        return self.get_response(request)
217

  
218

  
219
def journal_middleware(get_response):
220
    from . import journal
221

  
222
    def middleware(request):
223
        request.journal = journal.Journal(request=request)
224
        return get_response(request)
225

  
226
    return middleware
src/authentic2/settings.py
100 100
    'django.middleware.locale.LocaleMiddleware',
101 101
    'django.contrib.auth.middleware.AuthenticationMiddleware',
102 102
    'django.contrib.messages.middleware.MessageMiddleware',
103
    'authentic2.middleware.journal_middleware',
103 104
)
104 105

  
105 106
DATABASES['default']['ATOMIC_REQUESTS'] = True
src/authentic2/utils/__init__.py
447 447
    # prevent logint-hint to influence next use of the login page
448 448
    if 'login-hint' in request.session:
449 449
        del request.session['login-hint']
450
    request.journal.record('user.login', how=how)
450 451
    return continue_to_next_url(request, **kwargs)
451 452

  
452 453

  
......
757 758
                        legacy_html_body_templates=['registration/activation_email.html'])
758 759
    logger.info(u'registration mail sent to  %s with registration URL %s...', email,
759 760
                registration_url)
761
    request.journal.record('user.registration.request', email=email)
760 762

  
761 763

  
762 764
def send_account_deletion_code(request, user):
......
823 825
                             sign_next_url=True,
824 826
                             **kwargs):
825 827
    from .. import middleware
828
    from authentic2.journal import journal
826 829

  
827 830
    if not user.email:
828 831
        raise ValueError('user must have an email')
......
852 855
                        per_ou_templates=True, **kwargs)
853 856
    logger.info(u'password reset request for user %s, email sent to %s '
854 857
                'with token %s', user, user.email, token.uuid)
858
    journal.record('user.password.reset.request', email=user.email, user=user)
855 859

  
856 860

  
857 861
def batch(iterable, size):
src/authentic2/utils/service.py
52 52

  
53 53
def get_service_from_request(request):
54 54
    service_ref = request.GET.get(SERVICE_FIELD_NAME)
55
    if not service_ref or '\x00' in service_ref:
56
        return None
57
    return get_service_from_ref(service_ref)
55
    if service_ref and '\x00' not in service_ref:
56
        return get_service_from_ref(service_ref)
57
    return None
58

  
59

  
60
def get_service_from_session(request):
61
    session = getattr(request, 'session', None)
62
    if session and 'service_pk' in session:
63
        from authentic2.models import Service
64
        return Service.objects.get(pk=session['service_pk'])
65
    return None
58 66

  
59 67

  
60 68
def get_service_from_token(params):
src/authentic2/views.py
151 151
    def form_valid(self, form):
152 152
        response = super(EditProfile, self).form_valid(form)
153 153
        hooks.call_hooks('event', name='edit-profile', user=self.request.user, form=form)
154
        self.request.journal.record('user.profile.edit', form=form)
154 155
        return response
155 156

  
156 157
edit_profile = decorators.setting_enabled('A2_PROFILE_CAN_EDIT_PROFILE')(
......
575 576
        targets = redirect_logout_list(request)
576 577
        logger.debug('Accumulated redirections : {}'.format(targets))
577 578
        # Local logout
579
        request.journal.record('user.logout')
578 580
        auth_logout(request)
579 581
        logger.info('Logged out')
580 582
        local_logout_done = True
......
684 686

  
685 687
        if is_ratelimited(self.request, key='post:email', group='pw-reset-email',
686 688
                          rate=app_settings.A2_EMAILS_ADDRESS_RATELIMIT, increment=True):
689
            self.request.journal.record('user.password.reset.failure', email=email)
687 690
            form.add_error(
688 691
                'email',
689 692
                _('Multiple emails have already been sent to this address. Further attempts are '
......
692 695
            return self.form_invalid(form)
693 696
        if is_ratelimited(self.request, key='ip', group='pw-reset-email',
694 697
                          rate=app_settings.A2_EMAILS_IP_RATELIMIT, increment=True):
698
            self.request.journal.record('user.password.reset.failure', email=email)
695 699
            form.add_error(
696 700
                'email',
697 701
                _('Multiple password reset attempts have already been made from this IP address. No '
......
783 787
        return self.finish()
784 788

  
785 789
    def finish(self):
786
        return utils.simulate_authentication(self.request, self.user, 'email')
790
        response = utils.simulate_authentication(self.request, self.user, 'email')
791
        self.request.journal.record('user.password.reset')
792
        return response
787 793

  
788 794
password_reset_confirm = PasswordResetConfirmView.as_view()
789 795

  
......
1136 1142
        return self.registration_success(self.request, form.instance, form)
1137 1143

  
1138 1144
    def registration_success(self, request, user, form):
1145
        request.journal.record(
1146
            'user.registration',
1147
            user=user,
1148
            session=None,
1149
            how=self.authentication_method)
1139 1150
        hooks.call_hooks('event', name='registration', user=user, form=form, view=self,
1140 1151
                         authentication_method=self.authentication_method,
1141 1152
                         token=self.token, service=self.service and self.service.slug)
......
1233 1244
            self.user.mark_as_deleted()
1234 1245
            logger.info(u'deletion of account %s performed', self.user)
1235 1246
            hooks.call_hooks('event', name='delete-account', user=self.user)
1247
            request.journal.record('user.deletion', user=self.user)
1236 1248
            if self.user == request.user:
1237 1249
                # No validation message displayed, as the user will surely
1238 1250
                # notice their own account deletion...
......
1289 1301
        hooks.call_hooks('event', name='change-password', user=self.request.user, request=self.request)
1290 1302
        messages.info(self.request, _('Password changed'))
1291 1303
        models.PasswordReset.objects.filter(user=self.request.user).delete()
1292
        return super(PasswordChangeView, self).form_valid(form)
1304
        response = super(PasswordChangeView, self).form_valid(form)
1305
        self.request.journal.record('user.password.change', session=self.request.session)
1306
        return response
1293 1307

  
1294 1308
    def get_form_class(self):
1295 1309
        if self.request.user.has_usable_password():
src/authentic2_idp_oidc/views.py
287 287
                            expired=start + datetime.timedelta(days=365))
288 288
                        if pk_to_deletes:
289 289
                            auth_manager.filter(pk__in=pk_to_deletes).delete()
290
                        request.journal.record(
291
                            'user.service.sso.authorization',
292
                            service=client,
293
                            scopes=list(sorted(scopes)))
290 294
                        logger.info(u'authorized scopes %s saved for service %s', ' '.join(scopes),
291 295
                                    client.name)
292 296
                    else:
......
365 369
            })
366 370
        # query is transfered through the hashtag
367 371
        response = redirect(request, redirect_uri + '#%s' % urlencode(params), resolve=False)
372
    request.journal.record(
373
        'user.service.sso',
374
        service=client,
375
        how=last_auth and last_auth.get('how'))
368 376
    hooks.call_hooks('event', name='sso-success', idp='oidc', service=client, user=request.user)
369 377
    utils.add_oidc_session(request, client)
370 378
    return response
tests/conftest.py
126 126
    password = kwargs.pop('password', None) or kwargs['username']
127 127
    user, created = User.objects.get_or_create(**kwargs)
128 128
    if password:
129
        user.clear_password = password
129 130
        user.set_password(password)
130 131
        user.save()
131 132
    return user
......
167 168

  
168 169
@pytest.fixture
169 170
def user_ou2(db, ou2):
170
    return create_user(username='john.doe', first_name=u'Jôhn', last_name=u'Dôe',
171
    return create_user(username='john.doe.ou2', first_name=u'Jôhn', last_name=u'Dôe',
171 172
                       email='john.doe@example.net', ou=ou2)
172 173

  
173 174

  
tests/test_all.py
41 41

  
42 42
from authentic2 import utils, models, attribute_kinds
43 43

  
44
from .utils import Authentic2TestCase, get_response_form, get_link_from_mail
44
from .utils import Authentic2TestCase, get_response_form, get_link_from_mail, assert_event
45 45

  
46 46

  
47 47
class SerializerTests(TestCase):
......
215 215
        user = User.objects.create(username='testbot')
216 216
        user.set_password('secret')
217 217
        user.save()
218
        self.user = user
218 219
        self.client = Client()
219 220

  
220 221
    def test_edit_profile_attributes(self):
......
249 250
                          for k, v in kwargs.items())
250 251

  
251 252
        response = self.client.post(reverse('profile_edit'), kwargs)
253
        new = {'custom': 'random data', 'next_url': '', 'national_number': 'xx20153566342yy'}
254
        assert_event('user.profile.edit', user=self.user, session=self.client.session, old={}, new=new)
252 255

  
253 256
        self.assertEqual(response.status_code, 302)
254 257
        response = self.client.get(reverse('account_management'))
tests/test_idp_oidc.py
236 236
            assert authz.expired >= now()
237 237
        else:
238 238
            assert OIDCAuthorization.objects.count() == 0
239
        utils.assert_event('user.service.sso.authorization',
240
                           session=app.session,
241
                           user=simple_user, service=oidc_client,
242
                           scopes=['email', 'openid', 'profile'])
243
    utils.assert_event('user.service.sso', session=app.session,
244
                       user=simple_user, service=oidc_client,
245
                       how='password-on-https')
239 246
    if oidc_client.authorization_flow == oidc_client.FLOW_AUTHORIZATION_CODE:
240 247
        assert OIDCCode.objects.count() == 1
241 248
        code = OIDCCode.objects.get()
tests/test_login.py
21 21

  
22 22
from authentic2 import models
23 23

  
24
from .utils import login, check_log
24
from .utils import login, check_log, assert_event
25

  
26
User = get_user_model()
27

  
28

  
29
def test_success(db, app, simple_user):
30
    login(app, simple_user)
31
    assert_event('user.login', user=simple_user, session=app.session, how='password-on-https')
32
    session = app.session
33
    app.get('/logout/').form.submit()
34
    assert_event('user.logout', user=simple_user, session=session)
35

  
36

  
37
def test_failure(db, app, simple_user):
38
    login(app, simple_user, password='wrong', fail=True)
39
    assert_event('user.login.failure', username=simple_user.username)
25 40

  
26 41

  
27 42
def test_login_inactive_user(db, app):
28
    User = get_user_model()
29 43
    user1 = User.objects.create(username='john.doe')
30 44
    user1.set_password('john.doe')
31 45
    user1.save()
......
39 53
    assert '_auth_user_id' not in app.session
40 54
    user1.is_active = False
41 55
    user1.save()
42
    login(app, user1)
56
    login(app, user2)
43 57
    assert int(app.session['_auth_user_id']) == user2.id
44 58
    app.get('/logout/').form.submit()
45 59
    assert '_auth_user_id' not in app.session
tests/test_password_reset.py
23 23
def test_send_password_reset_email(app, simple_user, mailoutbox):
24 24
    from authentic2.utils import send_password_reset_mail
25 25
    assert len(mailoutbox) == 0
26
    send_password_reset_mail(
27
        simple_user,
28
        legacy_subject_templates=['registration/password_reset_subject.txt'],
29
        legacy_body_templates=['registration/password_reset_email.html'],
30
        context={
31
            'base_url': 'http://testserver',
32
        })
26
    with utils.run_on_commit_hooks():
27
        send_password_reset_mail(
28
            simple_user,
29
            legacy_subject_templates=['registration/password_reset_subject.txt'],
30
            legacy_body_templates=['registration/password_reset_email.html'],
31
            context={
32
                'base_url': 'http://testserver',
33
            })
33 34
    assert len(mailoutbox) == 1
35
    utils.assert_event('user.password.reset.request', user=simple_user, email=simple_user.email)
34 36
    url = utils.get_link_from_mail(mailoutbox[0])
35 37
    relative_url = url.split('testserver')[1]
36 38
    resp = app.get(relative_url, status=200)
......
38 40
    resp.form.set('new_password2', '1234==aA')
39 41
    resp = resp.form.submit().follow()
40 42
    assert str(app.session['_auth_user_id']) == str(simple_user.pk)
43
    utils.assert_event('user.password.reset', user=simple_user, session=app.session)
41 44

  
42 45

  
43 46
def test_view(app, simple_user, mailoutbox, settings):
......
47 50
    assert len(mailoutbox) == 0
48 51
    settings.DEFAULT_FROM_EMAIL = 'show only addr <noreply@example.net>'
49 52
    resp = resp.form.submit()
53
    utils.assert_event('user.password.reset.request', user=simple_user, email=simple_user.email)
50 54
    assert resp['Location'].endswith('/instructions/')
51 55
    resp = resp.follow()
52 56
    assert simple_user.email in resp.text
tests/test_registration.py
25 25
from authentic2 import utils, models
26 26
from authentic2.validators import EmailValidator
27 27

  
28
from .utils import get_link_from_mail
28
from .utils import get_link_from_mail, assert_event
29 29

  
30 30

  
31 31
User = get_user_model()
32 32

  
33 33

  
34
def test_registration(app, db, settings, mailoutbox, external_redirect):
34
def test_registration_success(app, db, settings, mailoutbox, external_redirect):
35 35
    next_url, good_next_url = external_redirect
36 36

  
37 37
    settings.LANGUAGE_CODE = 'en-us'
......
45 45
    response.form.set('email', 'testbot@entrouvert.com')
46 46
    response = response.form.submit()
47 47

  
48
    assert_event('user.registration.request', email='testbot@entrouvert.com')
48 49
    assert urlparse(response['Location']).path == reverse('registration_complete')
49 50
    if not good_next_url:
50 51
        assert not urlparse(response['Location']).query
......
87 88
    assert 'was successful' in mailoutbox[1].body
88 89

  
89 90
    new_user = User.objects.get()
91
    assert_event('user.registration', user=new_user, how='email')
90 92
    assert new_user.email == 'testbot@entrouvert.com'
91 93
    assert new_user.username is None
92 94
    assert new_user.check_password('T0==toto')
tests/test_utils.py
23 23

  
24 24
from django_rbac.utils import get_ou_model
25 25

  
26
from authentic2.journal import Journal
26 27
from authentic2.utils import (good_next_url, same_origin, select_next_url,
27 28
                              user_can_change_password, login,
28 29
                              get_authentication_events, authenticate,
......
91 92
    middleware = AuthenticationMiddleware()
92 93
    middleware.process_request(request)
93 94
    MessageMiddleware().process_request(request)
95
    request.journal = Journal(request=request)
94 96
    assert 'password' not in [ev['how'] for ev in get_authentication_events(request)]
95 97
    login(request, user, 'password')
96 98
    assert 'password' in [ev['how'] for ev in get_authentication_events(request)]
tests/test_views.py
16 16
# authentic2
17 17

  
18 18
import datetime
19
from .utils import login, logout, get_link_from_mail
19
from .utils import login, logout, get_link_from_mail, assert_event
20 20
import pytest
21 21

  
22 22
from django.urls import reverse
......
38 38
    simple_user.set_password('hop')
39 39
    simple_user.save()
40 40
    resp = login(app, simple_user, password='hop', path=reverse('password_change'))
41
    old_session_key = app.session.session_key
41 42

  
42 43
    resp.form['old_password'] = 'hop'
43 44
    resp.form['new_password1'] = 'hopAbcde1'
44 45
    resp.form['new_password2'] = 'hopAbcde1'
45 46
    resp = resp.form.submit()
46 47

  
48
    new_session_key = app.session.session_key
49

  
50
    assert old_session_key != new_session_key, 'session\'s key has not been cycled'
51

  
47 52
    assert resp.location == '/accounts/password/change/done/'
53
    assert_event('user.password.change', user=simple_user, session=app.session)
48 54

  
49 55

  
50 56
def test_account_delete(app, simple_user, mailoutbox):
......
84 90
    link = get_link_from_mail(mailoutbox[0])
85 91
    logout(app)
86 92
    page = app.get(link)
87
    assert 'You are about to delete the account of <strong>%s</strong>.' % \
88
            escape(simple_user.get_full_name()) in page.text
93
    assert 'You are about to delete the account of <strong>%s</strong>.' % escape(
94
        simple_user.get_full_name()) in page.text
89 95
    response = page.form.submit(name='delete').follow().follow()
90 96
    assert not User.objects.get(pk=simple_user.pk).is_active
91 97
    assert len(mailoutbox) == 2
......
105 111
    logout(app)
106 112
    login(app, user_ou1, path=reverse('account_management'))
107 113
    page = app.get(link)
108
    assert 'You are about to delete the account of <strong>%s</strong>.' % \
109
            escape(simple_user.get_full_name()) in page.text
114
    assert 'You are about to delete the account of <strong>%s</strong>.' % escape(
115
        simple_user.get_full_name()) in page.text
110 116
    response = page.form.submit(name='delete').follow()
111 117
    assert not User.objects.get(pk=simple_user.pk).is_active
112 118
    assert User.objects.get(pk=user_ou1.pk).is_active
......
117 123

  
118 124

  
119 125
def test_account_delete_fake_token(app, simple_user, mailoutbox):
120
    response = app.get(reverse('validate_deletion', kwargs={'deletion_token': 'thisismostlikelynotavalidtoken'})).follow().follow()
126
    response = (
127
        app.get(reverse('validate_deletion',
128
                kwargs={'deletion_token': 'thisismostlikelynotavalidtoken'}))
129
        .follow()
130
        .follow()
131
    )
121 132
    assert "The account deletion request is invalid, try again" in response.text
122 133

  
123 134

  
......
182 193
    response = response.form.submit()
183 194
    assert len(mailoutbox) == 3
184 195
    assert 'try again later' in response.text
196
    if view_name == 'password_reset':
197
        assert_event('user.password.reset.failure', email=simple_user.email)
185 198

  
186 199
    # reach ip limit
187 200
    for i in range(7):
tests/utils.py
30 30
from django.utils import six
31 31
from django.utils.six.moves.urllib import parse as urlparse
32 32

  
33
from authentic2 import utils
33
from authentic2 import utils, models
34
from authentic2.apps.journal.models import Event
34 35

  
35 36

  
36
def login(app, user, path=None, password=None, remember_me=None, args=None, kwargs=None):
37
def login(app, user, path=None, password=None, remember_me=None, args=None, kwargs=None, fail=False):
37 38
    if path:
38 39
        args = args or []
39 40
        kwargs = kwargs or {}
......
43 44
        login_page = app.get(reverse('auth_login'))
44 45
    assert login_page.request.path == reverse('auth_login')
45 46
    form = login_page.form
46
    form.set('username', user.username if hasattr(user, 'username') else user)
47
    username = user.username if hasattr(user, 'username') else user
48
    form.set('username', username)
47 49
    # password is supposed to be the same as username
48
    form.set('password', password or user.username)
50
    form.set('password', password or (user.clear_password if hasattr(user, 'clear_password') else username))
49 51
    if remember_me is not None:
50 52
        form.set('remember_me', bool(remember_me))
51
    response = form.submit(name='login-password-submit').follow()
52
    if path:
53
        assert response.request.path == path
53
    response = form.submit(name='login-password-submit')
54
    if fail:
55
        assert response.status_code == 200
56
        assert '_auth_user_id' not in app.session
54 57
    else:
55
        assert response.request.path == reverse('auth_homepage')
56
    assert '_auth_user_id' in app.session
58
        response = response.follow()
59
        if path:
60
            assert response.request.path == path
61
        else:
62
            assert response.request.path == reverse('auth_homepage')
63
        assert '_auth_user_id' in app.session
64
        assert not hasattr(user, 'id') or (app.session['_auth_user_id'] == str(user.id))
57 65
    return response
58 66

  
59 67

  
......
242 250
def text_content(node):
243 251
    '''Extract text content from node and all its children. Equivalent to
244 252
       xmlNodeGetContent from libxml.'''
245
    return u''.join(node.itertext())
253
    return u''.join(node.itertext()) if node is not None else ''
254

  
255

  
256
def assert_event(event_type_name, user=None, session=None, service=None, **data):
257
    qs = Event.objects.filter(type__name=event_type_name)
258
    if user:
259
        qs = qs.filter(user=user)
260
    else:
261
        qs = qs.filter(user__isnull=True)
262
    if session:
263
        qs = qs.filter(session=session.session_key)
264
    else:
265
        qs = qs.filter(session__isnull=True)
266
    if service:
267
        qs = qs.which_references(service)
268
    else:
269
        qs = qs.exclude(qs._which_references_query(models.Service))
270

  
271
    assert qs.count() == 1
272

  
273
    if data:
274
        event = qs.get()
275
        assert event.data, 'no event.data, should be %s' % data
276
        for key, value in data.items():
277
            assert event.data.get(key) == value, (
278
                'event.data[%s] != data[%s] (%s != %s)' % (key, key, event.data.get(key), value)
279
            )
246
-