misc: consider user-initiated account creation as authn event (#82736) #161

Open
pmarillonnet wants to merge 1 commits from wip/82736-account-create-as-recent-authn into main
10 changed files with 79 additions and 54 deletions

View File

@ -409,7 +409,13 @@ class LoginPasswordAuthenticator(BaseAuthenticator):
)
type = 'password'
how = ['password', 'password-on-https']
how = [
'email-with-password',
'email-with-password-on-https',
'phone-with-password',
'phone-with-password-on-https',
]
unique = True
protected = True

View File

@ -435,9 +435,9 @@ def build_assertion(request, login, provider, nid_format='transient'):
event = find_authentication_event(request, login.request.id)
logger.debug('authentication from stored event %s', event)
how = event['how']
if how == 'password':
if how.endswith('password'):
authn_context = lasso.SAML2_AUTHN_CONTEXT_PASSWORD
elif how == 'password-on-https':
elif how.endswith('password-on-https'):
authn_context = lasso.SAML2_AUTHN_CONTEXT_PASSWORD_PROTECTED_TRANSPORT
elif how.startswith('oath-totp'):
authn_context = lasso.SAML2_AUTHN_CONTEXT_TIME_SYNC_TOKEN

View File

@ -120,8 +120,10 @@ class EventTypeWithHow(EventTypeWithService):
def login_method_label(how):
if how.startswith('password'):
return _('password')
if how.startswith('email-with-password'):
return _('email and password')
elif how.startswith('phone-with-password'):
return _('phone number and password')
elif how == 'france-connect':
return 'FranceConnect'
elif how == 'saml':

View File

@ -227,7 +227,7 @@ class IdentifierChangeMixin(RecentAuthenticationMixin):
def can_validate_with_password(self):
last_event = utils_misc.last_authentication_event(self.request)
return last_event and last_event['how'] == 'password-on-https'
return last_event and last_event['how'].endswith('password-on-https')
def dispatch(self, request, *args, **kwargs):
if not self.can_validate_with_password() and not self.has_recent_authentication():
@ -1011,10 +1011,12 @@ def login_password_login(request, authenticator, *args, **kwargs):
if is_post:
csrf_token_check(request, form)
if form.is_valid():
if is_secure:
how = 'password-on-https'
if utils_misc.parse_phone_number(form.cleaned_data['username']):
how = 'phone-with-password'
else:
how = 'password'
how = 'email-with-password'
if is_secure:
how += '-on-https'
if form.cleaned_data.get('remember_me'):
request.session['remember_me'] = True
request.session.set_expiry(authenticator.remember_me)
@ -1659,7 +1661,6 @@ class InputSMSCodeView(cbv.ValidateCSRFMixin, FormView):
return self.form_invalid(form)
content = {
# TODO missing ou registration management
'authentication_method': 'phone',
'phone': self.code.phone,
'user': self.code.user.pk if self.code.user else None,
}
@ -1769,7 +1770,13 @@ class RegistrationCompletionView(CreateView):
# allow access to token content from external authentication backends
request.token = self.token
self.authentication_method = self.token.get('authentication_method', 'email')
method = 'phone' if self.token.get('phone') else 'email'
with_password = '-with-password' if not self.token.get('no_password', False) else ''
secure = '-on-https' if request.is_secure else ''
fallback_how = f'{method}{with_password}{secure}'
self.authentication_method = self.token.get('authentication_method', fallback_how)
if 'ou' in self.token:
self.ou = OU.objects.get(pk=self.token['ou'])
else:

View File

@ -607,7 +607,9 @@ class UnlinkView(FormView):
def must_set_password(self):
for event in self.request.session.get(constants.AUTHENTICATION_EVENTS_SESSION_KEY, []):
if event['how'].startswith('password'):
if event['how'].startswith('email-with-password') or event['how'].startswith(
'phone-with-password'
):
return False
return self.request.user.can_change_password()

View File

@ -245,7 +245,7 @@ def test_api_statistics(app, admin, freezer, event_type_name, event_name):
)
agenda_service = Service.objects.get(name='agendas')
method = {'how': 'password-on-https'}
method = {'how': 'email-with-password-on-https'}
method2 = {'how': 'france-connect'}
event_type = EventType.objects.get_for_name(event_type_name)
@ -271,7 +271,7 @@ def test_api_statistics(app, admin, freezer, event_type_name, event_name):
assert resp.json['data']['x_labels'] == ['2020-02', '2020-03']
assert resp.json['data']['series'] == [
{'label': 'FranceConnect', 'data': [None, 1]},
{'label': 'password', 'data': [2, 1]},
{'label': 'email and password', 'data': [2, 1]},
]
# default time interval is 'month'
@ -281,36 +281,36 @@ def test_api_statistics(app, admin, freezer, event_type_name, event_name):
resp = app.get(url, headers=headers, params={'services_ou': 'default', **params})
assert resp.json['data']['x_labels'] == ['2020-02', '2020-03']
assert resp.json['data']['series'] == [{'label': 'password', 'data': [1, 1]}]
assert resp.json['data']['series'] == [{'label': 'email and password', 'data': [1, 1]}]
resp = app.get(
url, headers=headers, params={'service': 'agendas default', 'users_ou': 'default', **params}
)
assert resp.json['data']['x_labels'] == ['2020-02']
assert resp.json['data']['series'] == [{'label': 'password', 'data': [1]}]
assert resp.json['data']['series'] == [{'label': 'email and password', 'data': [1]}]
resp = app.get(url, headers=headers, params={'users_ou': 'default', **params})
assert resp.json['data']['x_labels'] == ['2020-02']
assert resp.json['data']['series'] == [{'label': 'password', 'data': [1]}]
assert resp.json['data']['series'] == [{'label': 'email and password', 'data': [1]}]
resp = app.get(url, headers=headers, params={'service': 'agendas default', **params})
assert resp.json['data']['x_labels'] == ['2020-02', '2020-03']
assert resp.json['data']['series'] == [{'label': 'password', 'data': [1, 1]}]
assert resp.json['data']['series'] == [{'label': 'email and password', 'data': [1, 1]}]
resp = app.get(url, headers=headers, params={'start': '2020-03-01T01:01', **params})
assert resp.json['data']['x_labels'] == ['2020-03']
assert resp.json['data']['series'] == [
{'label': 'FranceConnect', 'data': [1]},
{'label': 'password', 'data': [1]},
{'label': 'email and password', 'data': [1]},
]
resp = app.get(url, headers=headers, params={'end': '2020-03-01T01:01', **params})
assert resp.json['data']['x_labels'] == ['2020-02']
assert resp.json['data']['series'] == [{'label': 'password', 'data': [2]}]
assert resp.json['data']['series'] == [{'label': 'email and password', 'data': [2]}]
resp = app.get(url, headers=headers, params={'end': '2020-03-01', **params})
assert resp.json['data']['x_labels'] == ['2020-02']
assert resp.json['data']['series'] == [{'label': 'password', 'data': [2]}]
assert resp.json['data']['series'] == [{'label': 'email and password', 'data': [2]}]
resp = app.get(
url, headers=headers, params={'time_interval': 'year', 'service': 'portal second', **params}
@ -318,7 +318,7 @@ def test_api_statistics(app, admin, freezer, event_type_name, event_name):
assert resp.json['data']['x_labels'] == ['2020']
assert resp.json['data']['series'] == [
{'label': 'FranceConnect', 'data': [1]},
{'label': 'password', 'data': [1]},
{'label': 'email and password', 'data': [1]},
]
if 'new' in event_name:

View File

@ -292,7 +292,7 @@ def test_authorization_code_sso(
session=app.session,
user=simple_user,
service=oidc_client,
how='password-on-https',
how='email-with-password-on-https',
)
if oidc_client.authorization_flow == oidc_client.FLOW_AUTHORIZATION_CODE:
assert OIDCCode.objects.count() == 1
@ -1253,7 +1253,7 @@ def test_registration_service_slug(oidc_settings, app, simple_oidc_client, simpl
assert hooks.event[1]['kwargs']['service'].slug == 'client'
assert hooks.event[2]['kwargs']['name'] == 'login'
assert hooks.event[2]['kwargs']['how'] == 'email'
assert hooks.event[2]['kwargs']['how'] == 'email-with-password-on-https'
assert hooks.event[2]['kwargs']['service'].slug == 'client'

View File

@ -480,7 +480,7 @@ def test_statistics(db, event_type_name, freezer):
agendas = Service.objects.create(name='agendas', slug='agendas', ou=get_default_ou())
forms = Service.objects.create(name='forms', slug='forms', ou=get_default_ou())
method = {'how': 'password-on-https'}
method = {'how': 'email-with-password-on-https'}
method2 = {'how': 'france-connect'}
event_type = EventType.objects.get_for_name(event_type_name)
@ -520,7 +520,7 @@ def test_statistics(db, event_type_name, freezer):
'series': [
{'label': 'None', 'data': [None, None, 1]},
{'label': 'FranceConnect', 'data': [None, 2, None]},
{'label': 'password', 'data': [2, None, 3]},
{'label': 'email and password', 'data': [2, None, 3]},
],
}
@ -540,7 +540,7 @@ def test_statistics(db, event_type_name, freezer):
'series': [
{'data': [None, 1], 'label': 'None'},
{'data': [2, None], 'label': 'FranceConnect'},
{'data': [2, 3], 'label': 'password'},
{'data': [2, 3], 'label': 'email and password'},
],
}
@ -548,7 +548,7 @@ def test_statistics(db, event_type_name, freezer):
assert stats == {
'x_labels': ['2020-03'],
'series': [
{'label': 'password', 'data': [2]},
{'label': 'email and password', 'data': [2]},
],
}
@ -557,7 +557,7 @@ def test_statistics(db, event_type_name, freezer):
'x_labels': ['2020-02', '2020-03'],
'series': [
{'label': 'FranceConnect', 'data': [2, None]},
{'label': 'password', 'data': [2, 1]},
{'label': 'email and password', 'data': [2, 1]},
],
}
@ -566,7 +566,7 @@ def test_statistics(db, event_type_name, freezer):
'x_labels': ['2020-02'],
'series': [
{'data': [1], 'label': 'FranceConnect'},
{'data': [1], 'label': 'password'},
{'data': [1], 'label': 'email and password'},
],
}
@ -575,14 +575,14 @@ def test_statistics(db, event_type_name, freezer):
'x_labels': ['2020-02', '2020-03'],
'series': [
{'label': 'FranceConnect', 'data': [2, None]},
{'label': 'password', 'data': [2, 1]},
{'label': 'email and password', 'data': [2, 1]},
],
}
stats = event_type_definition.get_method_statistics('month', service=agendas, users_ou=get_default_ou())
assert stats == {
'x_labels': ['2020-03'],
'series': [{'label': 'password', 'data': [1]}],
'series': [{'label': 'email and password', 'data': [1]}],
}
stats = event_type_definition.get_method_statistics('year')
@ -591,7 +591,7 @@ def test_statistics(db, event_type_name, freezer):
'series': [
{'data': [1], 'label': 'None'},
{'data': [2], 'label': 'FranceConnect'},
{'data': [5], 'label': 'password'},
{'data': [5], 'label': 'email and password'},
],
}
@ -619,7 +619,7 @@ def test_statistics(db, event_type_name, freezer):
def test_statistics_fill_date_gaps(db, freezer):
User.objects.create(username='john.doe', email='john.doe@example.com')
method = {'how': 'password-on-https'}
method = {'how': 'email-with-password-on-https'}
event_type = EventType.objects.get_for_name('user.login')
freezer.move_to('2020-12-29 12:00')
@ -632,7 +632,7 @@ def test_statistics_fill_date_gaps(db, freezer):
stats = event_type_definition.get_method_statistics('day')
assert stats == {
'x_labels': ['2020-12-29', '2020-12-30', '2020-12-31', '2021-01-01', '2021-01-02'],
'series': [{'label': 'password', 'data': [1, None, None, None, 1]}],
'series': [{'label': 'email and password', 'data': [1, None, None, None, 1]}],
}
Event.objects.all().delete()
@ -643,7 +643,7 @@ def test_statistics_fill_date_gaps(db, freezer):
stats = event_type_definition.get_method_statistics('month')
assert stats == {
'x_labels': ['2020-11', '2020-12'] + ['2021-%02d' % i for i in range(1, 13)] + ['2022-01', '2022-02'],
'series': [{'label': 'password', 'data': [1] + [None] * 14 + [1]}],
'series': [{'label': 'email and password', 'data': [1] + [None] * 14 + [1]}],
}
Event.objects.all().delete()
@ -654,7 +654,7 @@ def test_statistics_fill_date_gaps(db, freezer):
stats = event_type_definition.get_method_statistics('year')
assert stats == {
'x_labels': ['2020', '2021', '2022', '2023', '2024', '2025'],
'series': [{'label': 'password', 'data': [1, None, None, None, None, 1]}],
'series': [{'label': 'email and password', 'data': [1, None, None, None, None, 1]}],
}
@ -663,7 +663,7 @@ def test_statistics_deleted_service(db, freezer):
ou = OU.objects.create(name='Second OU')
portal = Service.objects.create(name='portal', slug='portal', ou=ou)
method = {'how': 'password-on-https'}
method = {'how': 'email-with-password-on-https'}
event_type = EventType.objects.get_for_name('user.login')
event_type_definition = event_type.definition
@ -691,7 +691,7 @@ def test_statistics_ou_with_no_service(db, freezer):
user = User.objects.create(username='john.doe', email='john.doe@example.com')
portal = Service.objects.create(name='portal', slug='portal', ou=get_default_ou())
method = {'how': 'password-on-https'}
method = {'how': 'email-with-password-on-https'}
event_type = EventType.objects.get_for_name('user.login')
event_type_definition = event_type.definition

View File

@ -31,7 +31,7 @@ User = get_user_model()
def test_success(db, app, simple_user):
login(app, simple_user)
assert_event('user.login', user=simple_user, session=app.session, how='password-on-https')
assert_event('user.login', user=simple_user, session=app.session, how='email-with-password-on-https')
session = app.session
app.get('/logout/').form.submit()
assert_event('user.logout', user=simple_user, session=session)
@ -39,7 +39,7 @@ def test_success(db, app, simple_user):
def test_success_email_with_phone_authn_activated(db, app, simple_user, settings, phone_activated_authn):
login(app, simple_user)
assert_event('user.login', user=simple_user, session=app.session, how='password-on-https')
assert_event('user.login', user=simple_user, session=app.session, how='email-with-password-on-https')
session = app.session
app.get('/logout/').form.submit()
assert_event('user.logout', user=simple_user, session=session)
@ -56,7 +56,7 @@ def test_success_email_with_phone_authn_nondefault_attribute(db, app, simple_use
phone_identifier_field=phone,
)
login(app, simple_user)
assert_event('user.login', user=simple_user, session=app.session, how='password-on-https')
assert_event('user.login', user=simple_user, session=app.session, how='email-with-password-on-https')
session = app.session
app.get('/logout/').form.submit()
assert_event('user.logout', user=simple_user, session=session)
@ -66,7 +66,7 @@ def test_success_phone_authn_nomail_user(db, app, nomail_user, settings, phone_a
nomail_user.attributes.phone = '+33123456789'
nomail_user.save()
login(app, nomail_user, login='123456789', phone_authn=True)
assert_event('user.login', user=nomail_user, session=app.session, how='password-on-https')
assert_event('user.login', user=nomail_user, session=app.session, how='phone-with-password-on-https')
session = app.session
app.get('/logout/').form.submit()
assert_event('user.logout', user=nomail_user, session=session)
@ -88,7 +88,7 @@ def test_success_phone_authn_nomail_user_nondefault_attribute(
nomail_user.attributes.another_phone = '+33123456789'
nomail_user.save()
login(app, nomail_user, login='123456789', phone_authn=True)
assert_event('user.login', user=nomail_user, session=app.session, how='password-on-https')
assert_event('user.login', user=nomail_user, session=app.session, how='phone-with-password-on-https')
session = app.session
app.get('/logout/').form.submit()
assert_event('user.logout', user=nomail_user, session=session)
@ -98,7 +98,7 @@ def test_success_phone_authn_nomail_user_nondefault_attribute(
nomail_user.phone = '+33122334455'
nomail_user.save()
login(app, nomail_user, login='123456789', phone_authn=True)
assert_event('user.login', user=nomail_user, session=app.session, how='password-on-https')
assert_event('user.login', user=nomail_user, session=app.session, how='phone-with-password-on-https')
session = app.session
app.get('/logout/').form.submit()
assert_event('user.logout', user=nomail_user, session=session)
@ -108,7 +108,7 @@ def test_success_phone_authn_simple_user(db, app, simple_user, settings, phone_a
simple_user.attributes.phone = '+33123456789'
simple_user.save()
login(app, simple_user, login='123456789', phone_authn=True)
assert_event('user.login', user=simple_user, session=app.session, how='password-on-https')
assert_event('user.login', user=simple_user, session=app.session, how='phone-with-password-on-https')
session = app.session
app.get('/logout/').form.submit()
assert_event('user.logout', user=simple_user, session=session)
@ -128,7 +128,7 @@ def test_success_phone_authn_simpler_user_nondefault_attribute(db, app, simple_u
simple_user.attributes.another_phone = '+33123456789'
simple_user.save()
login(app, simple_user, login='123456789', phone_authn=True)
assert_event('user.login', user=simple_user, session=app.session, how='password-on-https')
assert_event('user.login', user=simple_user, session=app.session, how='phone-with-password-on-https')
session = app.session
app.get('/logout/').form.submit()
assert_event('user.logout', user=simple_user, session=session)
@ -138,7 +138,7 @@ def test_success_phone_authn_simpler_user_nondefault_attribute(db, app, simple_u
simple_user.phone = '+33122334455'
simple_user.save()
login(app, simple_user, login='123456789', phone_authn=True)
assert_event('user.login', user=simple_user, session=app.session, how='password-on-https')
assert_event('user.login', user=simple_user, session=app.session, how='phone-with-password-on-https')
session = app.session
app.get('/logout/').form.submit()
assert_event('user.logout', user=simple_user, session=session)
@ -151,7 +151,7 @@ def test_success_phone_authn_ou_selector(db, app, nomail_user, settings, phone_a
nomail_user.attributes.phone = '+33123456789'
nomail_user.save()
login(app, nomail_user, login='123456789', phone_authn=True, ou=ou2)
assert_event('user.login', user=nomail_user, session=app.session, how='password-on-https')
assert_event('user.login', user=nomail_user, session=app.session, how='phone-with-password-on-https')
session = app.session
app.get('/logout/').form.submit()
assert_event('user.logout', user=nomail_user, session=session)
@ -159,13 +159,13 @@ def test_success_phone_authn_ou_selector(db, app, nomail_user, settings, phone_a
# no chosen OU, fallback on last chosen ou
login(app, nomail_user, login='123456789', phone_authn=True)
assert_event('user.login', user=nomail_user, session=app.session, how='password-on-https')
assert_event('user.login', user=nomail_user, session=app.session, how='phone-with-password-on-https')
app.get('/logout/').form.submit()
clear_events()
# no chosen OU, fallback on last chosen ou
login(app, nomail_user, login='123456789', phone_authn=True)
assert_event('user.login', user=nomail_user, session=app.session, how='password-on-https')
assert_event('user.login', user=nomail_user, session=app.session, how='phone-with-password-on-https')
app.get('/logout/').form.submit()
clear_events()

View File

@ -96,7 +96,7 @@ def test_registration_success(app, db, settings, mailoutbox, external_redirect):
assert 'was successful' in mailoutbox[1].body
new_user = User.objects.get()
assert_event('user.registration', user=new_user, how='email')
assert_event('user.registration', user=new_user, how='email-with-password-on-https')
assert not Event.objects.filter(type__name='user.login').exists()
assert new_user.email == 'testbot@entrouvert.com'
assert new_user.username is None
@ -105,6 +105,10 @@ def test_registration_success(app, db, settings, mailoutbox, external_redirect):
assert not new_user.is_staff
assert not new_user.is_superuser
assert str(app.session['_auth_user_id']) == str(new_user.pk)
assert app.session['authentication-events'][-1]['how'] == 'email-with-password-on-https'
# account creation is considered an authn, therefore identifier changes require a password input
assert 'password' in app.get(reverse('email-change')).form.fields
response = app.get('/login/')
response.form.set('username', 'testbot@entrouvert.com')
@ -657,9 +661,9 @@ def test_authentication_method(app, db, rf, hooks):
assert len(hooks.calls['event']) == 2
assert hooks.calls['event'][-2]['kwargs']['name'] == 'registration'
assert hooks.calls['event'][-2]['kwargs']['authentication_method'] == 'email'
assert hooks.calls['event'][-2]['kwargs']['authentication_method'] == 'email-on-https'
assert hooks.calls['event'][-1]['kwargs']['name'] == 'login'
assert hooks.calls['event'][-1]['kwargs']['how'] == 'email'
assert hooks.calls['event'][-1]['kwargs']['how'] == 'email-on-https'
activation_url = utils_misc.build_activation_url(
rf.post('/register/'),
@ -1130,6 +1134,10 @@ def test_phone_registration(app, db, settings, phone_activated_authn):
resp.form.set('last_name', 'Doe')
resp = resp.form.submit().follow()
assert 'You have just created an account' in resp.text
assert app.session['authentication-events'][-1]['how'] == 'phone-with-password-on-https'
# account creation is considered an authn, therefore identifier changes require a password input
assert 'password' in app.get(reverse('phone-change')).form.fields
user = User.objects.get(first_name='John', last_name='Doe')
assert user.attributes.phone == '+33612345678'