Skip to main content

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

· 3 min read

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