WIP: Signature de la next_url tout au long de la ré-initialisation de mot de passe par numéro de téléphone (#78096) #65

Draft
pmarillonnet wants to merge 3 commits from wip/78096-password-lost-phone-retrieval-next-url into wip/78157-phone-registration-enforce-signed-next-url
5 changed files with 111 additions and 13 deletions

View File

@ -42,6 +42,7 @@ logger = logging.getLogger(__name__)
class PasswordResetForm(HoneypotForm):
next_url = forms.CharField(widget=forms.HiddenInput, required=False)
next_url_signature = forms.CharField(widget=forms.HiddenInput, required=False)
email = ValidatedEmailField(label=_("Email"), required=False)

View File

@ -30,7 +30,7 @@
<div class="login-actions">
<ul>
{% if can_reset_password %}
<li><p>→ {% trans "Forgot password?" %} <a href="{% url 'password_reset' %}{% if request.GET.next %}?next={{ request.GET.next|urlencode }}{% endif %}">{% trans "Reset it!" %}</a></p></li>
<li><p>→ {% trans "Forgot password?" %} <a href="{{ password_reset_url }}">{% trans "Reset it!" %}</a></p></li>
{% endif %}
{% if registration_authorized and not hide_login_registration_link %}
<li><p>→ {% trans "Not a member?" %} <a href="{{ registration_url }}">{% trans "Register!" %}</a></p></li>

View File

@ -403,8 +403,10 @@ def redirect_to_login(
return redirect(request, login_url, keep_params=keep_params, include=include, **kwargs)
def continue_to_next_url(request, keep_params=True, include=(constants.NONCE_FIELD_NAME,), **kwargs):
next_url = select_next_url(request, settings.LOGIN_REDIRECT_URL, include_post=True)
def continue_to_next_url(
request, keep_params=True, include=(constants.NONCE_FIELD_NAME,), next_url=None, **kwargs
):
next_url = next_url or select_next_url(request, settings.LOGIN_REDIRECT_URL, include_post=True)
return redirect(request, to=next_url, keep_params=keep_params, include=include, **kwargs)
@ -460,7 +462,7 @@ def last_authentication_event(request=None, session=None):
return None
def login(request, user, how, nonce=None, record=True, **kwargs):
def login(request, user, how, nonce=None, record=True, next_url=None, **kwargs):
"""Login a user model, record the authentication event and redirect to next
URL or settings.LOGIN_REDIRECT_URL."""
from . import hooks
@ -481,7 +483,7 @@ def login(request, user, how, nonce=None, record=True, **kwargs):
del request.session['login-hint']
if record:
request.journal.record('user.login', how=how)
return continue_to_next_url(request, **kwargs)
return continue_to_next_url(request, next_url=next_url, **kwargs)
def login_require(request, next_url=None, login_url='auth_login', login_hint=(), token=None, **kwargs):
@ -765,6 +767,15 @@ def get_registration_url(request):
return make_url('registration_register', params=params, next_url=next_url, sign_next_url=True)
def get_password_reset_url(request):
next_url = select_next_url(request, settings.LOGIN_REDIRECT_URL)
next_url = make_url(
next_url, request=request, keep_params=True, include=(constants.NONCE_FIELD_NAME,), resolve=False
)
params = {REDIRECT_FIELD_NAME: next_url}
return make_url('password_reset', params=params, next_url=next_url, sign_next_url=True)
def get_token_login_url(user):
from authentic2.models import Token
@ -1023,13 +1034,15 @@ def get_good_origins():
return list(urls)
def good_next_url(request, next_url, signature_required=False):
def good_next_url(request, next_url, signature_required=False, signature=None):
'''Check if an URL is a good next_url'''
if not next_url:
return False
signature = request.POST.get(constants.NEXT_URL_SIGNATURE) or request.GET.get(
constants.NEXT_URL_SIGNATURE
signature = (
signature
or request.POST.get(constants.NEXT_URL_SIGNATURE)
or request.GET.get(constants.NEXT_URL_SIGNATURE)
)
if signature_required and not signature:
return False
@ -1176,7 +1189,7 @@ def same_origin(url1, url2):
return True
def simulate_authentication(request, user, method, backend=None, record=False, **kwargs):
def simulate_authentication(request, user, method, backend=None, record=False, next_url=None, **kwargs):
"""Simulate a normal login by eventually forcing a backend attribute on the
user instance"""
if not getattr(user, 'backend', None) and not backend:
@ -1184,7 +1197,7 @@ def simulate_authentication(request, user, method, backend=None, record=False, *
if backend:
user = copy.deepcopy(user)
user.backend = backend
return login(request, user, method, record=record, **kwargs)
return login(request, user, method, record=record, next_url=next_url, **kwargs)
def get_manager_login_url():

View File

@ -410,12 +410,14 @@ def login(request, template_name='authentic2/login.html', redirect_field_name=RE
blocks = []
registration_url = utils_misc.get_registration_url(request)
password_reset_url = utils_misc.get_password_reset_url(request)
context = {
'cancel': app_settings.A2_LOGIN_DISPLAY_A_CANCEL_BUTTON and nonce is not None,
'can_reset_password': app_settings.A2_USER_CAN_RESET_PASSWORD is not False,
'registration_authorized': registration_open,
'registration_url': registration_url,
'password_reset_url': password_reset_url,
}
# Cancel button
@ -860,7 +862,14 @@ class PasswordResetView(FormView):
if not app_settings.A2_ACCEPT_PHONE_AUTHENTICATION or not self.code: # user input is email
return reverse('password_reset_instructions')
else: # user input is phone number
return reverse('input_sms_code', kwargs={'token': self.code.url_token})
params = {}
if (next_url := getattr(self, 'next_url', None)) and (
signature := getattr(self, 'next_url_signature', None)
):
if crypto.check_hmac_url(settings.SECRET_KEY, next_url, signature):
params[REDIRECT_FIELD_NAME] = next_url
params[constants.NEXT_URL_SIGNATURE] = signature
return utils_misc.make_url('input_sms_code', kwargs={'token': self.code.url_token}, params=params)
def get_template_names(self):
return [
@ -872,7 +881,10 @@ class PasswordResetView(FormView):
kwargs = super().get_form_kwargs(**kwargs)
kwargs['password_authenticator'] = utils_misc.get_password_authenticator()
initial = kwargs.setdefault('initial', {})
initial['next_url'] = utils_misc.select_next_url(self.request, '')
if next_url := utils_misc.select_next_url(self.request, ''):
initial['next_url'] = next_url
if signature := self.request.GET.get(constants.NEXT_URL_SIGNATURE):
initial['next_url_signature'] = signature
return kwargs
def get_context_data(self, **kwargs):
@ -949,6 +961,10 @@ class PasswordResetView(FormView):
form.save()
elif phone:
if 'next_url' in form.cleaned_data:
self.next_url = form.cleaned_data['next_url']
self.next_url_signature = form.cleaned_data['next_url_signature']
if is_ratelimited(
self.request,
key=sms_ratelimit_key,
@ -1049,6 +1065,7 @@ class PasswordResetConfirmView(cbv.RedirectToNextURLViewMixin, FormView):
def dispatch(self, request, *args, **kwargs):
token = kwargs['token'].replace(' ', '')
self.next_url = utils_misc.select_next_url(request, None)
try:
self.token = models.Token.use('pw-reset', token, delete=False)
except models.Token.DoesNotExist:
@ -1102,7 +1119,9 @@ class PasswordResetConfirmView(cbv.RedirectToNextURLViewMixin, FormView):
return self.finish()
def finish(self):
response = utils_misc.simulate_authentication(self.request, self.user, 'email')
response = utils_misc.simulate_authentication(
self.request, self.user, 'email', next_url=self.next_url
)
self.request.journal.record('user.password.reset')
return response

View File

@ -111,6 +111,71 @@ def test_send_password_reset_by_sms_code(app, nomail_user, settings):
app.get(url, status=404)
def test_send_password_reset_by_sms_code_next_url(app, nomail_user, settings):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
settings.SMS_URL = 'https://foo.whatever.none/'
resp = app.get('/accounts/consents/').follow()
resp = resp.click('Reset it!')
resp.form.set('phone_1', '0123456789')
with HTTMock(sms_service_mock):
resp = resp.form.submit().follow().maybe_follow()
code = SMSCode.objects.get()
resp.form.set('sms_code', code.value)
resp = resp.form.submit().follow()
resp.form.set('new_password1', '1234==aA')
resp.form.set('new_password2', '1234==aA')
resp = resp.form.submit()
user = authenticate(username='user', password='1234==aA')
assert user == nomail_user
assert resp.location == '/accounts/consents/'
resp = resp.follow()
assert "Consent Management" in resp
def test_send_password_reset_by_sms_code_next_url_no_signature_is_ignored(app, nomail_user, settings):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
settings.SMS_URL = 'https://foo.whatever.none/'
url = reverse('password_reset') + '?next=/accounts/consents/'
resp = app.get(url)
resp.form.set('phone_1', '0123456789')
with HTTMock(sms_service_mock):
resp = resp.form.submit().follow().maybe_follow()
code = SMSCode.objects.get()
resp.form.set('sms_code', code.value)
resp = resp.form.submit().follow()
resp.form.set('new_password1', '1234==aA')
resp.form.set('new_password2', '1234==aA')
resp = resp.form.submit()
user = authenticate(username='user', password='1234==aA')
assert user == nomail_user
assert resp.location == '/'
def test_send_password_reset_by_sms_code_next_url_forged_signature_is_ignored(app, nomail_user, settings):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
settings.SMS_URL = 'https://foo.whatever.none/'
url = reverse('password_reset') + '?next=/accounts/consents/&amp;next-signature=abc'
resp = app.get(url)
resp.form.set('phone_1', '0123456789')
with HTTMock(sms_service_mock):
resp = resp.form.submit().follow().maybe_follow()
code = SMSCode.objects.get()
resp.form.set('sms_code', code.value)
resp = resp.form.submit().follow()
resp.form.set('new_password1', '1234==aA')
resp.form.set('new_password2', '1234==aA')
resp = resp.form.submit()
user = authenticate(username='user', password='1234==aA')
assert user == nomail_user
assert resp.location == '/'
def test_password_reset_empty_form(app, db, settings):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
settings.SMS_URL = 'https://foo.whatever.none/'