739 lines
29 KiB
Python
739 lines
29 KiB
Python
# authentic2 - versatile identity manager
|
|
# Copyright (C) 2010-2019 Entr'ouvert
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify it
|
|
# under the terms of the GNU Affero General Public License as published
|
|
# by the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU Affero General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
# authentic2
|
|
|
|
import datetime
|
|
from unittest import mock
|
|
from urllib.parse import urlparse
|
|
|
|
import pytest
|
|
import responses
|
|
from django.urls import reverse
|
|
from django.utils.html import escape
|
|
from django.utils.timezone import now
|
|
|
|
from authentic2.apps.authenticators.models import LoginPasswordAuthenticator
|
|
from authentic2.custom_user.models import DeletedUser, User
|
|
from authentic2.forms.passwords import PasswordChangeForm, SetPasswordForm
|
|
from authentic2.models import Attribute, SMSCode, Token
|
|
from authentic2.views import passive_login
|
|
|
|
from .utils import assert_event, get_link_from_mail, login, logout
|
|
|
|
pytestmark = pytest.mark.django_db
|
|
|
|
|
|
def test_profile(app, simple_user):
|
|
page = login(app, simple_user, path=reverse('account_management'))
|
|
assert simple_user.first_name in page
|
|
assert simple_user.last_name in page
|
|
|
|
|
|
def test_phone_number_change_invalid_number(settings, app, simple_user):
|
|
settings.A2_PROFILE_FIELDS = ('phone', 'mobile')
|
|
|
|
Attribute.objects.create(
|
|
kind='phone_number',
|
|
name='mobile',
|
|
label='Mobile',
|
|
user_visible=True,
|
|
user_editable=True,
|
|
)
|
|
|
|
simple_user.attributes.mobile = 'def' # invalid number
|
|
resp = login(app, simple_user, path='/accounts/edit/')
|
|
|
|
assert resp.pyquery('input#id_mobile_1')[0].value == 'def'
|
|
|
|
resp = resp.form.submit()
|
|
assert (
|
|
'Invalid phone number. Phone number from Metropolitan France must respect local format (e.g. 06 39 98 01 23).'
|
|
) == resp.pyquery('.error p')[0].text_content().strip()
|
|
|
|
resp.form['mobile_1'] = '612345678'
|
|
resp.form.submit().follow()
|
|
simple_user.refresh_from_db()
|
|
|
|
assert simple_user.attributes.mobile == '+33612345678'
|
|
|
|
|
|
def test_password_change(app, simple_user):
|
|
simple_user.set_password('hop')
|
|
simple_user.save()
|
|
resp = login(app, simple_user, password='hop', path='/accounts/password/change/')
|
|
old_session_key = app.session.session_key
|
|
|
|
assert resp.form['old_password'].attrs['autocomplete'] == 'current-password'
|
|
assert resp.form['new_password1'].attrs['autocomplete'] == 'new-password'
|
|
assert resp.form['new_password2'].attrs['autocomplete'] == 'new-password'
|
|
resp.form['old_password'] = 'hop'
|
|
resp.form['new_password1'] = 'hopAbcde1'
|
|
resp.form['new_password2'] = 'hopAbcde1'
|
|
resp = resp.form.submit()
|
|
|
|
assert resp.location == '/accounts/password/change/done/'
|
|
|
|
new_session_key = app.session.session_key
|
|
assert old_session_key != new_session_key, 'session\'s key has not been cycled'
|
|
assert_event('user.password.change', user=simple_user, session=app.session)
|
|
|
|
resp = resp.follow()
|
|
assert 'Password changed' in resp
|
|
|
|
|
|
def test_password_change_error(
|
|
app,
|
|
simple_user,
|
|
):
|
|
from authentic2.utils.misc import PasswordChangeError
|
|
|
|
simple_user.set_password('hop')
|
|
simple_user.save()
|
|
resp = login(app, simple_user, password='hop', path='/accounts/password/change/')
|
|
resp.form['old_password'] = 'hop'
|
|
resp.form['new_password1'] = 'hopAbcde1'
|
|
resp.form['new_password2'] = 'hopAbcde1'
|
|
|
|
with mock.patch(
|
|
'authentic2.custom_user.models.User.set_password', side_effect=PasswordChangeError('boum!')
|
|
):
|
|
resp = resp.form.submit()
|
|
|
|
assert 'Password changed' not in resp
|
|
assert 'boum!' in resp
|
|
|
|
|
|
def test_password_change_form(simple_user):
|
|
Attribute.objects.create(
|
|
kind='string',
|
|
name='favourite_song',
|
|
)
|
|
|
|
simple_user.attributes.favourite_song = '0opS 1 D1t iT @GAiN'
|
|
|
|
data = {
|
|
'new_password1': 'Password0',
|
|
'new_password2': 'Password0',
|
|
}
|
|
|
|
form = PasswordChangeForm(user=simple_user, data=data)
|
|
assert form.fields['new_password1'].widget.min_strength is None
|
|
assert 'new_password1' not in form.errors
|
|
|
|
LoginPasswordAuthenticator.objects.update(min_password_strength=3)
|
|
form = PasswordChangeForm(user=simple_user, data=data)
|
|
assert form.fields['new_password1'].widget.min_strength == 3
|
|
assert form.errors['new_password1'] == ['This password is not strong enough.']
|
|
|
|
data = {
|
|
'new_password1': '0opS 1 D1t iT @GAiN',
|
|
'new_password2': '0opS 1 D1t iT @GAiN',
|
|
}
|
|
form = PasswordChangeForm(user=simple_user, data=data)
|
|
assert form.errors['new_password1'] == ['This password is not strong enough.']
|
|
|
|
simple_user.attributes.favourite_song = 'Baby one more time'
|
|
form = PasswordChangeForm(user=simple_user, data=data)
|
|
assert 'new_password1' not in form.errors
|
|
|
|
|
|
def test_set_password_form(simple_user):
|
|
Attribute.objects.create(
|
|
kind='string',
|
|
name='favourite_song',
|
|
)
|
|
|
|
simple_user.attributes.favourite_song = '0opS 1 D1t iT @GAiN'
|
|
|
|
data = {
|
|
'new_password1': 'Password0',
|
|
'new_password2': 'Password0',
|
|
}
|
|
|
|
form = SetPasswordForm(user=simple_user, data=data)
|
|
assert form.fields['new_password1'].widget.min_strength is None
|
|
assert 'new_password1' not in form.errors
|
|
|
|
LoginPasswordAuthenticator.objects.update(min_password_strength=3)
|
|
form = SetPasswordForm(user=simple_user, data=data)
|
|
assert form.fields['new_password1'].widget.min_strength == 3
|
|
assert form.errors['new_password1'] == ['This password is not strong enough.']
|
|
|
|
data = {
|
|
'new_password1': '0opS 1 D1t iT @GAiN',
|
|
'new_password2': '0opS 1 D1t iT @GAiN',
|
|
}
|
|
form = SetPasswordForm(user=simple_user, data=data)
|
|
assert form.errors['new_password1'] == ['This password is not strong enough.']
|
|
|
|
simple_user.attributes.favourite_song = 'Baby one more time'
|
|
form = SetPasswordForm(user=simple_user, data=data)
|
|
assert 'new_password1' not in form.errors
|
|
|
|
|
|
def test_well_known_password_change(app):
|
|
resp = app.get('/.well-known/change-password')
|
|
assert resp.location == '/accounts/password/change/'
|
|
|
|
|
|
class TestDeleteAccountEmailVerified:
|
|
@pytest.fixture
|
|
def simple_user(self, simple_user):
|
|
simple_user.email_verified = True
|
|
simple_user.save()
|
|
return simple_user
|
|
|
|
def test_account_delete(self, app, simple_user, mailoutbox):
|
|
assert simple_user.is_active
|
|
assert len(mailoutbox) == 0
|
|
page = login(app, simple_user, path=reverse('delete_account'))
|
|
assert simple_user.email in page.text
|
|
page.form.submit(name='submit').follow()
|
|
assert len(mailoutbox) == 1
|
|
link = get_link_from_mail(mailoutbox[0])
|
|
assert mailoutbox[0].subject == 'Validate account deletion request on testserver'
|
|
assert [simple_user.email] == mailoutbox[0].to
|
|
page = app.get(link)
|
|
# FIXME: webtest does not set the Referer header, so the logout page will always ask for
|
|
# confirmation under tests
|
|
response = page.form.submit(name='delete')
|
|
assert '_auth_user_id' not in app.session
|
|
assert User.objects.filter(id=simple_user.id).count() == 0
|
|
assert DeletedUser.objects.filter(old_user_id=simple_user.id).count() == 1
|
|
assert len(mailoutbox) == 2
|
|
assert mailoutbox[1].subject == 'Account deletion on testserver'
|
|
assert mailoutbox[0].to == [simple_user.email]
|
|
assert 'Set-Cookie: messages=' in str(response) # Deletion performed
|
|
assert urlparse(response.location).path == '/'
|
|
|
|
def test_account_delete_when_logged_out(self, app, simple_user, mailoutbox):
|
|
assert simple_user.is_active
|
|
assert len(mailoutbox) == 0
|
|
page = login(app, simple_user, path=reverse('delete_account'))
|
|
page.form.submit(name='submit').follow()
|
|
assert len(mailoutbox) == 1
|
|
link = get_link_from_mail(mailoutbox[0])
|
|
logout(app)
|
|
page = app.get(link)
|
|
assert (
|
|
'You are about to delete the account of <strong>%s</strong>.'
|
|
% escape(simple_user.get_full_name())
|
|
in page.text
|
|
)
|
|
response = page.form.submit(name='delete')
|
|
assert User.objects.filter(id=simple_user.id).count() == 0
|
|
assert DeletedUser.objects.filter(old_user_id=simple_user.id).count() == 1
|
|
assert len(mailoutbox) == 2
|
|
assert mailoutbox[1].subject == 'Account deletion on testserver'
|
|
assert mailoutbox[0].to == [simple_user.email]
|
|
assert 'Set-Cookie: messages=' in str(response) # Deletion performed
|
|
assert urlparse(response.location).path == '/'
|
|
|
|
def test_account_delete_by_other_user(self, app, simple_user, user_ou1, mailoutbox):
|
|
assert simple_user.is_active
|
|
assert user_ou1.is_active
|
|
assert len(mailoutbox) == 0
|
|
page = login(app, simple_user, path=reverse('delete_account'))
|
|
page.form.submit(name='submit').follow()
|
|
assert len(mailoutbox) == 1
|
|
link = get_link_from_mail(mailoutbox[0])
|
|
logout(app)
|
|
login(app, user_ou1, path=reverse('account_management'))
|
|
page = app.get(link)
|
|
assert (
|
|
'You are about to delete the account of <strong>%s</strong>.'
|
|
% escape(simple_user.get_full_name())
|
|
in page.text
|
|
)
|
|
response = page.form.submit(name='delete')
|
|
assert app.session['_auth_user_id'] == str(user_ou1.id)
|
|
assert User.objects.filter(id=simple_user.id).count() == 0
|
|
assert DeletedUser.objects.filter(old_user_id=simple_user.id).count() == 1
|
|
assert len(mailoutbox) == 2
|
|
assert mailoutbox[1].subject == 'Account deletion on testserver'
|
|
assert mailoutbox[0].to == [simple_user.email]
|
|
assert 'Set-Cookie: messages=' in str(response) # Deletion performed
|
|
assert urlparse(response.location).path == '/'
|
|
|
|
def test_account_delete_fake_token(self, app, simple_user, mailoutbox):
|
|
response = (
|
|
app.get(reverse('validate_deletion', kwargs={'deletion_token': 'thisismostlikelynotavalidtoken'}))
|
|
.follow()
|
|
.follow()
|
|
)
|
|
assert 'The account deletion request is invalid, try again' in response.text
|
|
|
|
def test_account_delete_expired_token(self, app, simple_user, mailoutbox, freezer):
|
|
freezer.move_to('2019-08-01')
|
|
page = login(app, simple_user, path=reverse('delete_account'))
|
|
page.form.submit(name='submit').follow()
|
|
freezer.move_to('2019-08-04') # Too late...
|
|
link = get_link_from_mail(mailoutbox[0])
|
|
response = app.get(link).follow()
|
|
assert 'The account deletion request is too old, try again' in response.text
|
|
|
|
def test_account_delete_valid_token_unexistent_user(self, app, simple_user, mailoutbox):
|
|
page = login(app, simple_user, path=reverse('delete_account'))
|
|
page.form.submit(name='submit').follow()
|
|
link = get_link_from_mail(mailoutbox[0])
|
|
simple_user.delete()
|
|
response = app.get(link).follow().follow()
|
|
assert 'This account has previously been deleted.' in response.text
|
|
|
|
def test_account_delete_valid_token_inactive_user(self, app, simple_user, mailoutbox):
|
|
page = login(app, simple_user, path=reverse('delete_account'))
|
|
page.form.submit(name='submit').follow()
|
|
link = get_link_from_mail(mailoutbox[0])
|
|
simple_user.is_active = False
|
|
simple_user.save()
|
|
response = app.get(link).maybe_follow()
|
|
assert 'This account is inactive, it cannot be deleted.' in response.text
|
|
|
|
|
|
class TestDeleteAccountEmailNotVerified:
|
|
def test_account_delete(self, app, simple_user, mailoutbox):
|
|
assert simple_user.is_active
|
|
assert len(mailoutbox) == 0
|
|
page = login(app, simple_user, path=reverse('delete_account'))
|
|
response = page.form.submit(name='submit').follow()
|
|
assert '_auth_user_id' not in app.session
|
|
assert User.objects.filter(id=simple_user.id).count() == 0
|
|
assert DeletedUser.objects.filter(old_user_id=simple_user.id).count() == 1
|
|
assert len(mailoutbox) == 1
|
|
assert mailoutbox[0].subject == 'Account deletion on testserver'
|
|
assert mailoutbox[0].to == [simple_user.email]
|
|
assert 'Set-Cookie: messages=' in str(response) # Deletion performed
|
|
assert urlparse(response.location).path == '/'
|
|
|
|
def test_account_delete_old_authentication(self, app, simple_user, mailoutbox, freezer):
|
|
assert simple_user.is_active
|
|
assert len(mailoutbox) == 0
|
|
login(app, simple_user)
|
|
freezer.move_to(datetime.timedelta(hours=1))
|
|
redirect = app.get('/accounts/delete/')
|
|
login_page = redirect.follow()
|
|
assert 'You must re-authenticate' in login_page
|
|
login_page.form.set('password', simple_user.username)
|
|
page = login_page.form.submit(name='login-password-submit').follow()
|
|
response = page.form.submit(name='submit').follow()
|
|
assert '_auth_user_id' not in app.session
|
|
assert User.objects.filter(id=simple_user.id).count() == 0
|
|
assert DeletedUser.objects.filter(old_user_id=simple_user.id).count() == 1
|
|
assert len(mailoutbox) == 1
|
|
assert mailoutbox[0].subject == 'Account deletion on testserver'
|
|
assert mailoutbox[0].to == [simple_user.email]
|
|
assert 'Set-Cookie: messages=' in str(response) # Deletion performed
|
|
assert urlparse(response.location).path == '/'
|
|
|
|
|
|
@responses.activate
|
|
def test_delete_account_phone_identifier(app, nomail_user, settings, phone_activated_authn):
|
|
settings.SMS_URL = 'https://foo.whatever.none/'
|
|
responses.post('https://foo.whatever.none/', status=200)
|
|
|
|
nomail_user.attributes.phone = '+33122446666'
|
|
nomail_user.phone_verified_on = now()
|
|
nomail_user.save()
|
|
nomail_user_id = nomail_user.id
|
|
login(
|
|
app, nomail_user, login=nomail_user.attributes.phone, path='/accounts/', password=nomail_user.username
|
|
)
|
|
resp = app.get('/accounts/delete/')
|
|
assert 'A validation code will be sent to +33122446666' in resp.text
|
|
resp = resp.form.submit().follow()
|
|
code = SMSCode.objects.get()
|
|
assert not Token.objects.count()
|
|
resp.form.set('sms_code', code.value)
|
|
resp = resp.form.submit('').follow().maybe_follow()
|
|
# assert not Token.objects.count() # single use token?
|
|
with pytest.raises(User.DoesNotExist):
|
|
User.objects.get(id=nomail_user_id)
|
|
|
|
|
|
@responses.activate
|
|
def test_delete_account_phone_identifier_deactivated_user(app, nomail_user, settings, phone_activated_authn):
|
|
settings.SMS_URL = 'https://foo.whatever.none/'
|
|
responses.post('https://foo.whatever.none/', status=200)
|
|
|
|
nomail_user.attributes.phone = '+33122446666'
|
|
nomail_user.phone_verified_on = now()
|
|
nomail_user.save()
|
|
nomail_user_id = nomail_user.id
|
|
login(
|
|
app, nomail_user, login=nomail_user.attributes.phone, path='/accounts/', password=nomail_user.username
|
|
)
|
|
resp = app.get('/accounts/delete/')
|
|
resp = resp.form.submit().follow()
|
|
code = SMSCode.objects.get()
|
|
resp.form.set('sms_code', code.value)
|
|
nomail_user.is_active = False
|
|
nomail_user.save()
|
|
resp = resp.form.submit('').follow().maybe_follow()
|
|
# a user, submitting a deletion request, deactivated since then, should still
|
|
# be able to complete the deletion if the request has been submitted
|
|
with pytest.raises(User.DoesNotExist):
|
|
User.objects.get(id=nomail_user_id)
|
|
|
|
|
|
def test_delete_account_phone_verified_yet_missing(app, nomail_user, settings, phone_activated_authn):
|
|
settings.SMS_URL = 'https://foo.whatever.none/'
|
|
|
|
nomail_user.attributes.phone = '+33122446666'
|
|
nomail_user.phone_verified_on = now()
|
|
nomail_user.save()
|
|
login(
|
|
app, nomail_user, login=nomail_user.attributes.phone, path='/accounts/', password=nomail_user.username
|
|
)
|
|
# some improper use, e.g. in backoffice where phones can be arbitrarily erased
|
|
nomail_user.attributes.phone = ''
|
|
nomail_user.save()
|
|
resp = app.get('/accounts/delete/')
|
|
assert resp.pyquery('form p')[-1].text.strip() == 'Do you really want to delete your account?'
|
|
assert 'validation code' not in resp.form.html
|
|
assert '+33122446666' not in resp.html
|
|
|
|
|
|
@responses.activate
|
|
def test_delete_account_verified_email_precedence_over_verified_phone(
|
|
app, simple_user, settings, phone_activated_authn
|
|
):
|
|
settings.SMS_URL = 'https://foo.whatever.none/'
|
|
responses.post('https://foo.whatever.none/', status=200)
|
|
|
|
simple_user.attributes.phone = '+33122446666'
|
|
simple_user.email_verified = True
|
|
simple_user.phone_verified_on = now()
|
|
simple_user.save()
|
|
login(
|
|
app, simple_user, login=simple_user.attributes.phone, path='/accounts/', password=simple_user.username
|
|
)
|
|
resp = app.get('/accounts/delete/')
|
|
# email is verified and defaults as deletion code exchange means
|
|
assert 'A validation message will be sent to user@example.net.' in resp.text
|
|
resp.form.submit().follow()
|
|
assert not SMSCode.objects.count()
|
|
|
|
|
|
@responses.activate
|
|
def test_delete_account_verified_phone_precedence_over_unverified_email(
|
|
app, simple_user, settings, phone_activated_authn
|
|
):
|
|
settings.SMS_URL = 'https://foo.whatever.none/'
|
|
responses.post('https://foo.whatever.none/', status=200)
|
|
|
|
simple_user.attributes.phone = '+33122446666'
|
|
simple_user.email_verified = False
|
|
simple_user.phone_verified_on = now()
|
|
simple_user.save()
|
|
login(
|
|
app, simple_user, login=simple_user.attributes.phone, path='/accounts/', password=simple_user.username
|
|
)
|
|
resp = app.get('/accounts/delete/')
|
|
# email is unverified and skipped as deletion code exchange means
|
|
# fallback on phone
|
|
assert 'A validation code will be sent to +33122446666' in resp.text
|
|
resp.form.submit().follow()
|
|
assert SMSCode.objects.get()
|
|
|
|
|
|
@responses.activate
|
|
def test_delete_account_unverified_identifiers_direct_deletion(
|
|
app, simple_user, settings, phone_activated_authn
|
|
):
|
|
settings.SMS_URL = 'https://foo.whatever.none/'
|
|
responses.post('https://foo.whatever.none/', status=200)
|
|
|
|
simple_user.attributes.phone = '+33122446666'
|
|
simple_user.email_verified = False
|
|
simple_user.phone_verified_on = None
|
|
simple_user.save()
|
|
simple_user_id = simple_user.id
|
|
login(
|
|
app, simple_user, login=simple_user.attributes.phone, path='/accounts/', password=simple_user.username
|
|
)
|
|
resp = app.get('/accounts/delete/')
|
|
# email is unverified but so is the user's phone
|
|
# deletion process is direct
|
|
assert 'A validation message' not in resp.text
|
|
assert 'A validation code' not in resp.text
|
|
resp.form.submit().follow()
|
|
assert not SMSCode.objects.count()
|
|
assert not Token.objects.count()
|
|
with pytest.raises(User.DoesNotExist):
|
|
User.objects.get(id=simple_user_id)
|
|
|
|
|
|
@responses.activate
|
|
def test_delete_account_phone_identifier_changed_in_between(
|
|
app, nomail_user, settings, phone_activated_authn
|
|
):
|
|
settings.SMS_URL = 'https://foo.whatever.none/'
|
|
responses.post('https://foo.whatever.none/', status=200)
|
|
|
|
nomail_user.attributes.phone = '+33122446666'
|
|
nomail_user.phone_verified_on = now()
|
|
nomail_user.save()
|
|
nomail_user_id = nomail_user.id
|
|
login(
|
|
app, nomail_user, login=nomail_user.attributes.phone, path='/accounts/', password=nomail_user.username
|
|
)
|
|
resp = app.get('/accounts/delete/')
|
|
assert 'A validation code will be sent to +33122446666' in resp.text
|
|
resp = resp.form.submit().follow()
|
|
code = SMSCode.objects.get()
|
|
nomail_user.attributes.phone = '+33122446688'
|
|
nomail_user.save()
|
|
resp.form.set('sms_code', code.value)
|
|
resp = resp.form.submit('').follow().maybe_follow()
|
|
assert 'Something went wrong' in resp.text
|
|
assert User.objects.get(id=nomail_user_id)
|
|
|
|
|
|
def test_login_invalid_next(app):
|
|
app.get(reverse('auth_login') + '?next=plop')
|
|
|
|
|
|
def test_custom_account(settings, app, simple_user):
|
|
response = login(app, simple_user, path=reverse('account_management'))
|
|
assert response.status_code == 200
|
|
settings.A2_ACCOUNTS_URL = 'http://combo/account/'
|
|
response = app.get(reverse('account_management'))
|
|
assert response.status_code == 302
|
|
assert response['Location'] == settings.A2_ACCOUNTS_URL
|
|
|
|
|
|
@pytest.mark.parametrize('view_name', ['registration_register', 'password_reset', 'phone-change'])
|
|
@responses.activate
|
|
def test_views_sms_ratelimit(app, db, simple_user, settings, freezer, view_name, phone_activated_authn):
|
|
freezer.move_to('2020-01-01')
|
|
LoginPasswordAuthenticator.objects.update(sms_ip_ratelimit='10/h', sms_number_ratelimit='3/d')
|
|
settings.SMS_SENDER = 'EO'
|
|
settings.SMS_URL = 'https://www.example.com/send'
|
|
responses.post('https://www.example.com/send', status=200)
|
|
|
|
if view_name in ('phone-change',):
|
|
login(app, simple_user)
|
|
response = app.get(reverse(view_name))
|
|
response.form.set('phone_0', '33')
|
|
response.form.set('phone_1', '0612345678')
|
|
if view_name in ('phone-change',):
|
|
response.form.set('password', simple_user.username)
|
|
response = response.form.submit()
|
|
assert 'try again later' not in response.text
|
|
for _ in range(2):
|
|
response = app.get(reverse(view_name))
|
|
response.form.set('phone_0', '33')
|
|
response.form.set('phone_1', '0612345678')
|
|
if view_name in ('phone-change',):
|
|
response.form.set('password', simple_user.username)
|
|
response = response.form.submit()
|
|
assert 'An SMS code has already been sent' in response.text
|
|
if view_name in ('phone-change',):
|
|
response.form.set('password', simple_user.username)
|
|
response = response.form.submit()
|
|
assert 'try again later' not in response.text
|
|
|
|
response = app.get(reverse(view_name))
|
|
response.form.set('phone_0', '33')
|
|
response.form.set('phone_1', '0612345678')
|
|
if view_name in ('phone-change',):
|
|
response.form.set('password', simple_user.username)
|
|
response = response.form.submit()
|
|
if view_name in ('phone-change',):
|
|
response.form.set('password', simple_user.username)
|
|
response = response.form.submit()
|
|
assert 'try again later' in response.text
|
|
|
|
suffixes = iter(range(6000, 9999))
|
|
# reach ip limit
|
|
for _ in range(7):
|
|
response = app.get(reverse(view_name))
|
|
random_suffix = next(suffixes)
|
|
response.form.set('phone_0', '33')
|
|
response.form.set('phone_1', f'061234{random_suffix:04d}')
|
|
if view_name in ('phone-change',):
|
|
response.form.set('password', simple_user.username)
|
|
response = response.form.submit()
|
|
assert 'try again later' not in response.text
|
|
|
|
response = app.get(reverse(view_name))
|
|
random_suffix = next(suffixes)
|
|
response.form.set('phone_0', '33')
|
|
response.form.set('phone_1', f'061234{random_suffix:04d}')
|
|
if view_name in ('phone-change',):
|
|
response.form.set('password', simple_user.username)
|
|
response = response.form.submit()
|
|
assert 'try again later' in response.text
|
|
|
|
# ip ratelimits are lifted after an hour
|
|
freezer.tick(datetime.timedelta(hours=1))
|
|
response = app.get(reverse(view_name))
|
|
random_suffix = next(suffixes)
|
|
response.form.set('phone_0', '33')
|
|
response.form.set('phone_1', f'061234{random_suffix:04d}')
|
|
if view_name in ('phone-change',):
|
|
response.form.set('password', simple_user.username)
|
|
response = response.form.submit()
|
|
assert 'try again later' not in response.text
|
|
|
|
# identifier ratelimits are lifted after a day
|
|
response = app.get(reverse(view_name))
|
|
response.form.set('phone_0', '33')
|
|
response.form.set('phone_1', '0612345678')
|
|
if view_name in ('phone-change',):
|
|
response.form.set('password', simple_user.username)
|
|
response = response.form.submit()
|
|
assert 'Multiple SMSs have already been sent to this number.' in response.text
|
|
if view_name in ('phone-change',):
|
|
response.form.set('password', simple_user.username)
|
|
response = response.form.submit()
|
|
assert 'try again later' in response.text
|
|
|
|
freezer.tick(datetime.timedelta(days=1))
|
|
response = app.get(reverse(view_name))
|
|
response.form.set('phone_0', '33')
|
|
response.form.set('phone_1', '0612345678')
|
|
if view_name in ('phone-change',):
|
|
response.form.set('password', simple_user.username)
|
|
response = response.form.submit()
|
|
assert 'try again later' not in response.text
|
|
|
|
|
|
@pytest.mark.parametrize('view_name', ['registration_register', 'password_reset'])
|
|
def test_views_email_ratelimit(app, db, simple_user, settings, mailoutbox, freezer, view_name):
|
|
freezer.move_to('2020-01-01')
|
|
LoginPasswordAuthenticator.objects.update(emails_ip_ratelimit='10/h', emails_address_ratelimit='3/d')
|
|
users = [User.objects.create(email='test%s@test.com' % i) for i in range(8)]
|
|
|
|
# reach email limit
|
|
for _ in range(3):
|
|
response = app.get(reverse(view_name))
|
|
response.form.set('email', simple_user.email)
|
|
response = response.form.submit()
|
|
assert len(mailoutbox) == 3
|
|
|
|
response = app.get(reverse(view_name))
|
|
response.form.set('email', simple_user.email)
|
|
response = response.form.submit()
|
|
assert len(mailoutbox) == 3
|
|
assert 'try again later' in response.text
|
|
if view_name == 'password_reset':
|
|
assert_event('user.password.reset.failure', email=simple_user.email)
|
|
|
|
# reach ip limit
|
|
for i in range(7):
|
|
response = app.get(reverse(view_name))
|
|
response.form.set('email', users[i].email)
|
|
response = response.form.submit()
|
|
assert len(mailoutbox) == 10
|
|
|
|
response = app.get(reverse(view_name))
|
|
response.form.set('email', users[i + 1].email)
|
|
response = response.form.submit()
|
|
assert len(mailoutbox) == 10
|
|
assert 'try again later' in response.text
|
|
|
|
# ip ratelimits are lifted after an hour
|
|
freezer.tick(datetime.timedelta(hours=1))
|
|
response = app.get(reverse(view_name))
|
|
response.form.set('email', users[0].email)
|
|
response = response.form.submit()
|
|
assert len(mailoutbox) == 11
|
|
|
|
# email ratelimits are lifted after a day
|
|
response = app.get(reverse(view_name))
|
|
response.form.set('email', simple_user.email)
|
|
response = response.form.submit()
|
|
assert len(mailoutbox) == 11
|
|
assert 'try again later' in response.text
|
|
|
|
freezer.tick(datetime.timedelta(days=1))
|
|
response = app.get(reverse(view_name))
|
|
response.form.set('email', simple_user.email)
|
|
response = response.form.submit()
|
|
assert len(mailoutbox) == 12
|
|
|
|
|
|
@pytest.mark.parametrize('view_name', ['registration_register', 'password_reset'])
|
|
def test_views_email_token_resend(app, simple_user, settings, mailoutbox, view_name):
|
|
settings.A2_TOKEN_EXISTS_WARNING = True
|
|
|
|
response = app.get(reverse(view_name))
|
|
response.form.set('email', simple_user.email)
|
|
response = response.form.submit()
|
|
assert len(mailoutbox) == 1
|
|
|
|
# warn user token has already been sent
|
|
response = app.get(reverse(view_name))
|
|
response.form.set('email', simple_user.email)
|
|
response = response.form.submit()
|
|
assert 'email has already been sent' in response.text
|
|
assert len(mailoutbox) == 1
|
|
|
|
# validating again anyway works
|
|
response = response.form.submit()
|
|
assert len(mailoutbox) == 2
|
|
|
|
|
|
def test_views_login_display_a_cancel_button(app, settings):
|
|
response = app.get(reverse('auth_login'), params={'next': '/foo/', 'nonce': 'xxx'})
|
|
assert not response.html.find('button', {'class': 'cancel-button'})
|
|
|
|
settings.A2_LOGIN_DISPLAY_A_CANCEL_BUTTON = True
|
|
response = app.get(reverse('auth_login'), params={'next': '/foo/', 'nonce': 'xxx'})
|
|
assert response.html.find('button', {'class': 'cancel-button'})
|
|
|
|
|
|
def test_set_home_url(settings, app, simple_user, service, monkeypatch):
|
|
from authentic2.models import Service
|
|
|
|
settings.A2_REDIRECT_WHITELIST = ['https://example.com/', 'https://not-example.com/']
|
|
monkeypatch.setattr(Service, 'get_base_urls', lambda self: ['https://example.com/'])
|
|
|
|
login(app, simple_user)
|
|
assert 'service_pk' not in app.session
|
|
app.get('/accounts/?next=https://example.com/')
|
|
assert app.session['service_pk'] == service.pk
|
|
app.get('/accounts/?next=https://not-example.com/')
|
|
assert 'service_pk' not in app.session
|
|
|
|
|
|
def test_redirected_views(app):
|
|
assert app.get('/accounts/register/').location == '/register/'
|
|
assert (
|
|
app.get('/accounts/password/reset/confirm/abcd1234/').location == '/password/reset/confirm/abcd1234/'
|
|
)
|
|
|
|
|
|
def test_passive_login(rf):
|
|
from django.contrib.sessions.middleware import SessionMiddleware
|
|
|
|
req = rf.get('/')
|
|
SessionMiddleware(lambda x: None).process_request(req)
|
|
assert passive_login(req, next_url='/', login_hint={'pop'}) is None
|
|
|
|
authenticator1 = mock.Mock()
|
|
authenticator1.show.return_value = True
|
|
authenticator1.passive_login.return_value = 'response1'
|
|
authenticator2 = mock.Mock()
|
|
authenticator2.show.return_value = True
|
|
authenticator2.passive_login.return_value = 'response2'
|
|
|
|
with mock.patch(
|
|
'authentic2.utils.misc.get_authenticators', return_value=[authenticator1, authenticator2]
|
|
):
|
|
assert passive_login(req, next_url='/', login_hint={'pop'}) == 'response1'
|