From 12526f44d736b360a2c92a46eabed1c455e1b4fa Mon Sep 17 00:00:00 2001 From: Dirk Alders Date: Sat, 9 Nov 2024 06:58:41 +0100 Subject: [PATCH] Password recovery implemented --- __init__.py | 33 ++++++++------- context.py | 20 +++++++-- emails.py | 23 ++++++++++ forms.py | 10 ++++- templates/users/email_recover.html | 8 ++++ templates/users/recover.html | 11 +++++ urls.py | 3 +- views.py | 67 ++++++++++++++++++++++++++++-- 8 files changed, 151 insertions(+), 24 deletions(-) create mode 100644 templates/users/email_recover.html create mode 100644 templates/users/recover.html diff --git a/__init__.py b/__init__.py index b4799af..d072b15 100644 --- a/__init__.py +++ b/__init__.py @@ -1,26 +1,29 @@ from django.urls.base import reverse -def url_login(request): - nxt = request.GET.get('next', request.get_full_path()) - return reverse('users-login') + '?next=%s' % nxt +def url_by_name(name, request, just_url=False): + base_url = reverse(name) + if just_url: + return base_url + else: + nxt = request.GET.get('next', request.get_full_path()) + return base_url + '?next=%s' % nxt -def url_logout(request): - nxt = request.GET.get('next', request.get_full_path()) - return reverse('users-logout') + '?next=%s' % nxt +def url_login(request, just_url=False): + return url_by_name('users-login', request, just_url) -def url_profile(request): - nxt = request.GET.get('next', request.get_full_path()) - return reverse('users-profile') + '?next=%s' % nxt +def url_logout(request, just_url=False): + return url_by_name('users-logout', request, just_url) -def url_register(request): - nxt = request.GET.get('next', request.get_full_path()) - return reverse('users-register') + '?next=%s' % nxt +def url_profile(request, just_url=False): + return url_by_name('users-profile', request, just_url) -def url_password_recovery(request): - nxt = request.GET.get('next', request.get_full_path()) - return reverse('users-password-recovery') + '?next=%s' % nxt +def url_recover(request, just_url=False): + return url_by_name('users-recover', request, just_url) + +def url_register(request, just_url=False): + return url_by_name('users-register', request, just_url) diff --git a/context.py b/context.py index 2f7843e..858aebc 100644 --- a/context.py +++ b/context.py @@ -1,13 +1,14 @@ from django.urls.base import reverse from django.utils.translation import gettext as _ from themes import empty_entry_parameters, color_icon_url -from . import url_login, url_logout, url_register, url_profile +from . import url_login, url_logout, url_register, url_profile, url_recover from . import parameter ADMIN_ENTRY_UID = 'admin-main' LOGIN_ENTRY_UID = 'login-main' LOGOUT_ENTRY_UID = 'logout-main' PROFILE_ENTRY_UID = 'profile-main' +RECOVER_ENTRY_UID = 'recover-main' REGISTER_ENTRY_UID = 'register-main' @@ -32,6 +33,7 @@ def menubar(bar, request): def actionbar(bar, request): bar.append_entry(*login_entry_parameters(request, left=True)) + bar.append_entry(*recover_entry_parameters(request, left=True)) if parameter.get(parameter.USERS_SELF_REGISTRATION): bar.append_entry(*register_entry_parameters(request, left=True)) @@ -43,10 +45,20 @@ def login_entry_parameters(request, left=False): color_icon_url(request, 'login.png'), # icon url_login(request), # url left, # left - False # active + request.path == url_login(request, True) # active ) +def recover_entry_parameters(request, left=False): + return ( + RECOVER_ENTRY_UID, # uid + _('Recover'), # name + color_icon_url(request, 'recover.png'), # icon + url_recover(request), # url + left, # left + request.path == url_recover(request, True) # active + ) + def register_entry_parameters(request, left=False): return ( REGISTER_ENTRY_UID, # uid @@ -54,7 +66,7 @@ def register_entry_parameters(request, left=False): color_icon_url(request, 'register.png'), # icon url_register(request), # url left, # left - False # active + request.path == url_register(request, True) # active ) @@ -87,5 +99,5 @@ def profile_entry_parameters(request): color_icon_url(request, 'user.png'), # icon url_profile(request), # url False, # left - False # active + request.path == url_profile(request, True) # active ) diff --git a/emails.py b/emails.py index 01e20ce..d1e4eaa 100644 --- a/emails.py +++ b/emails.py @@ -30,6 +30,12 @@ def send_validation_failed(uid, token): mail_admins(subject, message, fail_silently=True) +def send_recover_failed(uid, token): + subject = "Password recovery failed!" + message = f"uid = {uid}\ntoken = '{token}'" + mail_admins(subject, message, fail_silently=True) + + def send_welcome_mail(new_user: User): subject = "Welcome!" message = """Hello %(username)s. @@ -56,3 +62,20 @@ def send_validation_mail(new_user, request): [new_user.email], ) send_mail(email_subject, message2, settings.EMAIL_FROM, [new_user.email], fail_silently=True) + + +def send_recover_mail(user, request): + email_subject = "Password recovery" + message2 = render_to_string('users/email_recover.html', { + 'name': user.first_name, + 'base_url': request.build_absolute_uri('/')[:-1], + 'uid': urlsafe_base64_encode(force_bytes(user.pk)), + 'token': generate_token.make_token(user) + }) + email = EmailMessage( + email_subject, + message2, + settings.EMAIL_HOST_USER, + [user.email], + ) + send_mail(email_subject, message2, settings.EMAIL_FROM, [user.email], fail_silently=True) diff --git a/forms.py b/forms.py index 181e2d1..f91dbd7 100644 --- a/forms.py +++ b/forms.py @@ -1,11 +1,19 @@ from django import forms from django.contrib.auth.models import User -from django.contrib.auth.forms import UserCreationForm, UserChangeForm, PasswordChangeForm +from django.contrib.auth.forms import AuthenticationForm, UserCreationForm, UserChangeForm, PasswordChangeForm, SetPasswordForm from django.utils.translation import gettext as _ from .models import UserProfile, get_userprofile from users import emails +class PasswordRecoverForm(AuthenticationForm): + password = None + + +class PasswordRecoverChangeForm(SetPasswordForm): + fields = ["new_password1", "new_password2"] + + class UserRegistrationForm(UserCreationForm): email = forms.EmailField() diff --git a/templates/users/email_recover.html b/templates/users/email_recover.html new file mode 100644 index 0000000..25a6e1c --- /dev/null +++ b/templates/users/email_recover.html @@ -0,0 +1,8 @@ +{% autoescape off %} + + Hello {{ name }}, + + you are able to recover your password by clicking on the following link. + Recovery Link: {{ base_url }}{% url 'users-recover-token' uidb64=uid token=token %} + + {% endautoescape %} diff --git a/templates/users/recover.html b/templates/users/recover.html new file mode 100644 index 0000000..1ad0f85 --- /dev/null +++ b/templates/users/recover.html @@ -0,0 +1,11 @@ +{% extends "themes/"|add:settings.page_theme|add:"/base.html" %} +{% load i18n %} + +{% block content %} + +
+ {% csrf_token %} + {{ form.as_p }} + +
+{% endblock content %} diff --git a/urls.py b/urls.py index 065bc68..88273db 100644 --- a/urls.py +++ b/urls.py @@ -4,9 +4,10 @@ from . import views urlpatterns = [ path('login', views.login, name='users-login'), path('logout', views.logout, name='users-logout'), - path('password_recovery', views.password_recovery, name='users-password-recovery'), path('profile', views.profile, name='users-profile'), path('register', views.register, name='users-register'), + path('recover', views.recover, name='users-recover'), + path('recover-token//', views.recover_token, name='users-recover-token'), path('validate//', views.validate, name='users-validate'), path('activate/', views.activate, name='users-activate'), ] diff --git a/views.py b/views.py index 406a51b..ef5564d 100644 --- a/views.py +++ b/views.py @@ -11,7 +11,7 @@ from django.contrib.auth.models import User from django.utils.encoding import force_str from django.utils.http import urlsafe_base64_decode from django.utils.translation import gettext as _ -from .forms import UserRegistrationForm, UserProfileForm, UserActivationForm, UserPasswordChangeForm +from .forms import PasswordRecoverForm, UserRegistrationForm, UserProfileForm, UserActivationForm, UserPasswordChangeForm, PasswordRecoverChangeForm import logging from .models import get_userprofile from themes import Context @@ -59,6 +59,26 @@ def profile(request): ) return render(request, 'users/profile.html', context=context) +def recover(request): + context = Context(request) # needs to be executed first because of time mesurement + context_adaption(context, request, _('Password Recovery')) + if not request.POST: + form = PasswordRecoverForm(request) + else: + username = request.POST.get("username") + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + pass # hide non existing user (just do nothing) + else: + profile = get_userprofile(user) + if profile.mail_validated: + emails.send_recover_mail(user, request) + # + messages.info(request, _("If the user exists, you will get a reover email.")) + return redirect("users-login") + context['form'] = form + return render(request, 'users/recover.html', context) def register(request): context = Context(request) # needs to be executed first because of time mesurement @@ -116,10 +136,10 @@ def login(request): if is_active: if parameter.get(parameter.USERS_SELF_REGISTRATION): messages.error(request, _('Login failed! You can do a password recorvery here or you can register here.') % - {'url_register': users.url_register(request), 'url_recover': users.url_password_recovery(request)}) + {'url_register': users.url_register(request), 'url_recover': users.url_recover(request)}) else: messages.error(request, _('Login failed! You can do a password recorvery here.') % - {'url_recover': users.url_password_recovery(request)}) + {'url_recover': users.url_recover(request)}) else: messages.info(request, _("The account is deactivated. Confirm your email adress and wait for the administrator to activate your account.")) @@ -227,3 +247,44 @@ def activate(request, pk): user_to_be_activated.delete() messages.info(request, _("User deleted.")) return redirect("/") + + +def recover_token(request, uidb64, token): + context = Context(request) # needs to be executed first because of time mesurement + print(settings.PASSWORD_RESET_TIMEOUT) + try: + uid = force_str(urlsafe_base64_decode(uidb64)) + except (TypeError, ValueError, OverflowError, User.DoesNotExist): + uid = None + myuser = None + else: + try: + myuser = User.objects.get(pk=uid) + except User.DoesNotExist: + myuser = None + + if myuser is not None and generate_token.check_token(myuser, token): + if request.POST: + form = PasswordRecoverChangeForm(myuser, data=request.POST) + if form.is_valid(): + form.save() + return redirect(request.GET.get('next') or 'users-login') + else: + form = PasswordRecoverChangeForm(myuser) + # + context_adaption( + context, + request, + _('Password recovery for %(username)s') % {'username': myuser.username}, + form=form, + ) + return render(request, 'users/recover.html', context=context) + else: + context_adaption( + context, + request, + _('Recovery failed'), + ) + messages.info(request, _("Recovery failed. The system administrator will be informed.")) + emails.send_recover_failed(uid, token) + return redirect("/")