django

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

  • вторник, 23 сентября 2014 г. в 03:10:26
http://habrahabr.ru/post/237701/

Такой вопрос пришел сегодня в техподдержку. Пользователь заходит на страницу восстановления пароля, вводит свой 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?! Как там говорится, «явное лучше неявного»?

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