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
|
@ -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)
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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():
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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/&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/'
|
||||
|
|
Loading…
Reference in New Issue