Pull to refresh

Почему я не могу сбросить пароль?

Reading time 3 min
Views 24K
Такой вопрос пришел сегодня в техподдержку. Пользователь заходит на страницу восстановления пароля, вводит свой email, нажимает кнопку «Восстановить». Система радостно сообщает, что email отправлен. Пользователь заходит в почтовый ящик, пользователь не видит письма, пользователь недоволен.

Далее следует стандартное: «Проверьте правильность ввода email'а, убедитесь, что письмо не попало в спам». Проверили-убедились, не помогло. Захожу на почтовый сервер — письмо даже не было отправлено.

Отрываюсь от всех дел и бросаюсь в тестирование. Захожу на страницу восстановления, ввожу свой email — все в порядке, письмо со ссылкой на восстановление пароля приходит. Ввожу email пользователя — тишина. Письмо не отправляется. В логе — ничего (от слова «совсем»).

Далее следует с полчаса бесполезных метаний, немного недоумения и много нецензурной лексики. Успокаиваемся, делаем глубокий вдох и лезем в исходный код Django.

За сброс пароля отвечает password_reset:
Скрытый текст
@csrf_protect
def password_reset(request, is_admin_site=False,
                   template_name='registration/password_reset_form.html',
                   email_template_name='registration/password_reset_email.html',
                   subject_template_name='registration/password_reset_subject.txt',
                   password_reset_form=PasswordResetForm,
                   token_generator=default_token_generator,
                   post_reset_redirect=None,
                   from_email=None,
                   current_app=None,
                   extra_context=None):
    if post_reset_redirect is None:
        post_reset_redirect = reverse('password_reset_done')
    else:
        post_reset_redirect = resolve_url(post_reset_redirect)
    if request.method == "POST":
        form = password_reset_form(request.POST)
        if form.is_valid():
            opts = {
                'use_https': request.is_secure(),
                'token_generator': token_generator,
                'from_email': from_email,
                'email_template_name': email_template_name,
                'subject_template_name': subject_template_name,
                'request': request,
            }
            if is_admin_site:
                opts = dict(opts, domain_override=request.get_host())
            form.save(**opts)
            return HttpResponseRedirect(post_reset_redirect)
    else:
        form = password_reset_form()
    context = {
        'form': form,
    }
    if extra_context is not None:
        context.update(extra_context)
    return TemplateResponse(request, template_name, context,
                            current_app=current_app)


Раз редирект на post_reset_redirect происходит, значит, form.save() выполняется. Смотрим, что у него под капотом:
Скрытый текст
def save(self, domain_override=None,
             subject_template_name='registration/password_reset_subject.txt',
             email_template_name='registration/password_reset_email.html',
             use_https=False, token_generator=default_token_generator,
             from_email=None, request=None):
        """
        Generates a one-use only link for resetting password and sends to the
        user.
        """
        from django.core.mail import send_mail
        UserModel = get_user_model()
        email = self.cleaned_data["email"]
        active_users = UserModel._default_manager.filter(
            email__iexact=email, is_active=True)
        for user in active_users:
            # Make sure that no email is sent to a user that actually has
            # a password marked as unusable
            if not user.has_usable_password():
                continue
            if not domain_override:
                current_site = get_current_site(request)
                site_name = current_site.name
                domain = current_site.domain
            else:
                site_name = domain = domain_override
            c = {
                'email': user.email,
                'domain': domain,
                'site_name': site_name,
                'uid': urlsafe_base64_encode(force_bytes(user.pk)),
                'user': user,
                'token': token_generator.make_token(user),
                'protocol': 'https' if use_https else 'http',
            }
            subject = loader.render_to_string(subject_template_name, c)
            # Email subject *must not* contain newlines
            subject = ''.join(subject.splitlines())
            email = loader.render_to_string(email_template_name, c)
            send_mail(subject, email, from_email, [user.email])


Тут, конечно, до меня доходит. Пользователь сначала регистрировался через ВКонтакте. Потом поставил email. И отвязал ВКонтакте. И вот в процессе регистрации через ВК ему, т.е. пользователю, назначали set_unusable_password() (ибо пароля-то у него не было).

Весь этот поток эмоций был вызван вот этими строками:

# Make sure that no email is sent to a user that actually has
# a password marked as unusable
if not user.has_usable_password():
  continue

Зачем? Почему? Кто виноват? Почему нельзя сбросить пароль, если изначально он не был задан? А главное, почему система об этом никак не сообщает, а, хихикая, перенаправляет на post_reset_redirect?! Как там говорится, «явное лучше неявного»?

В общем, имейте в виду. И не наступайте на эти грабли.

Update:

Спасибо товарищу zzeus:
Users flagged with an unusable password (see set_unusable_password() aren’t allowed to request a password reset to prevent misuse when using an external authentication source like LDAP. Note that they won’t receive any error message since this would expose their account’s existence but no mail will be sent either.

Ну и в связи с этим небольшой опрос, интересно ж.
Only registered users can participate in poll. Log in, please.
Читаете ли вы обычно документацию?
21.93% да, перед тем, как начать что-то делать 91
65.3% да, когда все сломается 271
12.77% нет, лучше спросить на форуме / у коллеги 53
415 users voted. 85 users abstained.
Tags:
Hubs:
+16
Comments 13
Comments Comments 13

Articles