From 77fe30dc346d747083b0c691b3f04fad5b01e140 Mon Sep 17 00:00:00 2001 From: Walter Lorenzetti Date: Mon, 13 Nov 2023 12:23:51 +0100 Subject: [PATCH] :sparkles: Change password at first login (#650) * Add PASSWORD_CHANGE_FIRST_LOGIN setting. * Add change_password_first_login property to Userdata model. * Create custom G3WSetPasswordForm and custom G3WLoginView to use for change password at first login workflow. * Start testing. * Testing. * Split change password at first login from reset password workflow. * Create a custom view for confirm reset password to split User rest password workflow from Change password at first login worflow. * Override settings fix. --------- Co-authored-by: wlorenzetti --- g3w-admin/base/settings/base.py | 4 + g3w-admin/base/settings/tests_settings.py | 6 ++ g3w-admin/base/urls.py | 35 ++++++-- g3w-admin/usersmanage/forms.py | 19 +++- ...14_userdata_change_password_first_login.py | 18 ++++ g3w-admin/usersmanage/models.py | 2 + g3w-admin/usersmanage/tests/test_views.py | 86 ++++++++++++++++++- g3w-admin/usersmanage/views.py | 42 ++++++++- 8 files changed, 201 insertions(+), 11 deletions(-) create mode 100644 g3w-admin/usersmanage/migrations/0014_userdata_change_password_first_login.py diff --git a/g3w-admin/base/settings/base.py b/g3w-admin/base/settings/base.py index a7cc88c8b..13a932204 100644 --- a/g3w-admin/base/settings/base.py +++ b/g3w-admin/base/settings/base.py @@ -331,6 +331,10 @@ # must be done by the administrator REGISTRATION_ACTIVE_BY_ADMIN = False +# CHANGE PASSWORD AT FIRST LOGIN +# ------------------------------ +PASSWORD_CHANGE_FIRST_LOGIN = False + # QPLOTLY DEFAULT SETTINGS # ------------------------ diff --git a/g3w-admin/base/settings/tests_settings.py b/g3w-admin/base/settings/tests_settings.py index 1aa246ff2..597e45077 100644 --- a/g3w-admin/base/settings/tests_settings.py +++ b/g3w-admin/base/settings/tests_settings.py @@ -45,3 +45,9 @@ 'worker_type': 'process', }, } + +# LOGIN/REGISTRATION/RESET USER/PASSWORD +# ====================================== +# Add this setting here because test override_settings decorator +# does not work at django bootstrap i.e. inside main urls.py module. +PASSWORD_CHANGE_FIRST_LOGIN = True diff --git a/g3w-admin/base/urls.py b/g3w-admin/base/urls.py index 950749d88..4bea8cf2c 100644 --- a/g3w-admin/base/urls.py +++ b/g3w-admin/base/urls.py @@ -19,13 +19,16 @@ from usersmanage.forms import ( G3WAuthenticationForm, G3WResetPasswordForm, - G3WRegistrationForm + G3WRegistrationForm, + G3WSetPasswordForm ) from usersmanage.views import ( G3WUserRegistrationView, G3WUsernameRecoveryView, G3WUsernameRecoveryDoneView, - G3WPasswordResetView + G3WPasswordResetView, + G3WLoginView, + G3WPasswordChangeFirstLoginConfirmView ) from ajax_select import urls as ajax_select_urls @@ -100,7 +103,7 @@ ), path( 'login/', - auth.views.LoginView.as_view( + G3WLoginView.as_view( template_name='login.html', form_class=G3WAuthenticationForm, extra_context=extra_context_login_page @@ -122,11 +125,11 @@ include(ajax_select_urls) ) ] + ############################################################# # REGISTRATION USERS ############################################################# -#path('accounts/', include('django_registration.backends.activation.urls')), urlpatterns += [ path( "accounts/activate/complete/", @@ -198,7 +201,9 @@ ), path( 'reset///', - auth.views.PasswordResetConfirmView.as_view(extra_context=extra_context_login_page), + auth.views.PasswordResetConfirmView.as_view( + extra_context=extra_context_login_page, + form_class=G3WSetPasswordForm), name='password_reset_confirm' ), path( @@ -220,6 +225,26 @@ ), ] +############################################################# +# CHANGE PASSWORD FIRST LOGIN +############################################################# +if settings.PASSWORD_CHANGE_FIRST_LOGIN: + urlpatterns += [ + path( + 'changepassword///', + G3WPasswordChangeFirstLoginConfirmView.as_view( + extra_context=extra_context_login_page, + form_class=G3WSetPasswordForm), + name='change_password_first_login_confirm' + ), + path( + 'changepassword/done/', + auth.views.PasswordResetCompleteView.as_view( + extra_context=extra_context_login_page), + name='change_password_first_login_complete' + ), + ] + ############################################################# # API URLs ############################################################# diff --git a/g3w-admin/usersmanage/forms.py b/g3w-admin/usersmanage/forms.py index 5ca9161fd..2885ba163 100644 --- a/g3w-admin/usersmanage/forms.py +++ b/g3w-admin/usersmanage/forms.py @@ -17,7 +17,8 @@ UserCreationForm, ReadOnlyPasswordHashField, AuthenticationForm, - PasswordResetForm + PasswordResetForm, + SetPasswordForm ) from django.contrib.auth import ( password_validation, @@ -923,4 +924,20 @@ def clean_email(self): return self.cleaned_data['email'] +class G3WSetPasswordForm(SetPasswordForm): + """ + Custom SetPasswordForm for G3W-SUITE + """ + + def save(self, commit=True): + + if (settings.PASSWORD_CHANGE_FIRST_LOGIN and + not self.user.is_superuser and + not self.user.userdata.change_password_first_login and + not self.user.userdata.registered): + self.user.userdata.change_password_first_login = True + self.user.userdata.save() + + return super().save(commit=commit) + diff --git a/g3w-admin/usersmanage/migrations/0014_userdata_change_password_first_login.py b/g3w-admin/usersmanage/migrations/0014_userdata_change_password_first_login.py new file mode 100644 index 000000000..9048400f3 --- /dev/null +++ b/g3w-admin/usersmanage/migrations/0014_userdata_change_password_first_login.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.23 on 2023-11-10 08:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('usersmanage', '0013_auto_20230922_0815'), + ] + + operations = [ + migrations.AddField( + model_name='userdata', + name='change_password_first_login', + field=models.BooleanField(blank=True, default=False, null=True, verbose_name='User changed password at first login'), + ), + ] diff --git a/g3w-admin/usersmanage/models.py b/g3w-admin/usersmanage/models.py index 3a543986a..1e3772f28 100644 --- a/g3w-admin/usersmanage/models.py +++ b/g3w-admin/usersmanage/models.py @@ -23,6 +23,8 @@ class Userdata(models.Model): registered = models.BooleanField(_('Registered user'), default=False, blank=True, null=True) activated_by_admin = models.BooleanField(_('User activated by administrator'), default=False, null=True, blank=True) + change_password_first_login = models.BooleanField(_('User changed password at first login'), default=False, + null=True, blank=True) USER_BACKEND_TYPES = G3WChoices( diff --git a/g3w-admin/usersmanage/tests/test_views.py b/g3w-admin/usersmanage/tests/test_views.py index 597eb4325..d23a4b8d4 100644 --- a/g3w-admin/usersmanage/tests/test_views.py +++ b/g3w-admin/usersmanage/tests/test_views.py @@ -10,12 +10,20 @@ __date__ = '2020-04-14' __copyright__ = 'Copyright 2015 - 2020, Gis3w' +from django.test import override_settings from django.test import Client -from django.urls import reverse +from django.urls import reverse, resolve from django.core.exceptions import ObjectDoesNotExist from django.contrib.auth.models import Group as AuthGroup, User +from usersmanage.models import Userdata from .base import BaseUsermanageTestCase -from .utils import setup_testing_user_relations, assign_perm, G3W_EDITOR1 +from .utils import ( + setup_testing_user_relations, + assign_perm, G3W_EDITOR1, + G3W_VIEWER1, + USER_BACKEND_DEFAULT, + Userbackend +) class UsermanageViewsTest(BaseUsermanageTestCase): @@ -270,6 +278,80 @@ def test_user_groups_delete(self): self.assertEqual(response.status_code, 404) self.client.logout() + def test_change_password_first_login(self): + """ Test for change password first login workflow""" + + # Create new user + new_user_data = { + 'username': 'new_user', + 'password': 'new_user' + } + new_user = User.objects.create_user(**new_user_data) + new_user.save() + new_user.groups.add(self.main_roles[G3W_VIEWER1]) + Userbackend(user=new_user, backend=USER_BACKEND_DEFAULT).save() + Userdata.objects.create(user=new_user).save() + + + login_url = reverse('login') + + # Test login + res = self.client.post(login_url, data=new_user_data) + + # Redirect to reset_password page + self.assertEqual(res.status_code, 302) + self.assertTrue(resolve(res.url).view_name, 'change_password_first_login_confirm') + + # with /set-password/ + res = self.client.get(res.url) + self.assertEqual(res.status_code, 302) + self.assertTrue(resolve(res.url).view_name, 'change_password_first_login_confirm') + + # Reset password + new_password_data = { + 'new_password1': 'jaskjT678873u5@#', + 'new_password2': 'jaskjT678873u5@#' + } + + + res = self.client.post(res.url, data=new_password_data) + self.assertEqual(res.status_code, 302) + self.assertEqual(resolve(res.url).view_name, "change_password_first_login_complete") + + # Check userdata of user + new_user.refresh_from_db() + + self.assertEqual(new_user.userdata.change_password_first_login, True) + + # Login again with new password + new_user_data = { + 'username': 'new_user', + 'password': 'jaskjT678873u5@#' + } + + res = self.client.post(login_url, data=new_user_data) + + self.assertEqual(res.status_code, 302) + self.assertEqual(res.url, "/") + + # Admin user not redirect + # Create new user + new_admin_user_data = { + 'username': 'new_admin_user', + 'password': 'new_admin_user' + } + new_admin_user = User.objects.create_user(**new_admin_user_data) + new_admin_user.is_superuser = True + new_admin_user.save() + Userbackend(user=new_admin_user, backend=USER_BACKEND_DEFAULT).save() + + res = self.client.post(login_url, data=new_admin_user_data) + + self.assertEqual(res.status_code, 302) + self.assertEqual(res.url, "/") + + + diff --git a/g3w-admin/usersmanage/views.py b/g3w-admin/usersmanage/views.py index 958905deb..74c6e23f8 100644 --- a/g3w-admin/usersmanage/views.py +++ b/g3w-admin/usersmanage/views.py @@ -6,16 +6,24 @@ DetailView, View ) -from django.http.response import JsonResponse, Http404 +from django.http.response import JsonResponse, Http404, HttpResponseRedirect from django.views.generic.detail import SingleObjectMixin from django.utils.decorators import method_decorator from django.utils.translation import ugettext_lazy as _ +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode from django.core.mail import send_mail +from django.contrib.auth.tokens import default_token_generator from django.contrib.auth.decorators import permission_required from django.contrib.auth.models import Group +from django.contrib.auth.views import ( + PasswordResetView, + PasswordResetDoneView, + LoginView, + PasswordResetConfirmView +) from django.contrib.sites.shortcuts import get_current_site from django.template.loader import render_to_string -from django.contrib.auth.views import PasswordResetView, PasswordResetDoneView from django.urls import reverse_lazy from guardian.shortcuts import assign_perm, get_objects_for_user from guardian.decorators import permission_required_or_403 @@ -23,7 +31,7 @@ from core.mixins.views import G3WRequestViewMixin, G3WAjaxDeleteViewMixin from core.models import GeneralSuiteData from .decorators import permission_required_by_backend_or_403 -from .utils import getUserGroups, get_user_groups +from .utils import getUserGroups, get_user_groups, userHasGroups from .configs import * from .forms import * import json @@ -381,6 +389,13 @@ class G3WPasswordResetView(G3WUserPasswordRecoveryMixin, PasswordResetView): """ pass +class G3WPasswordChangeFirstLoginConfirmView(G3WUserPasswordRecoveryMixin, PasswordResetConfirmView): + """ + Class for G3W-SUITE for Password change at first login + """ + + success_url = reverse_lazy('change_password_first_login_complete') + class G3WUsernameRecoveryView(G3WUserPasswordRecoveryMixin, PasswordResetView): """ @@ -400,3 +415,24 @@ class G3WUsernameRecoveryDoneView(PasswordResetDoneView): """ template_name = 'registration/username_recovery_done.html' title = _('Username sent') + +class G3WLoginView(LoginView): + """ + Custom Login View for G3W-SUITE + """ + + def form_valid(self, form): + + # Check password at first login is active + # If true redirect to reset password form + user = form.get_user() + if (settings.PASSWORD_CHANGE_FIRST_LOGIN and + not user.is_superuser and + not user.userdata.change_password_first_login and + not user.userdata.registered): + return HttpResponseRedirect(reverse('change_password_first_login_confirm', args=[ + urlsafe_base64_encode(force_bytes(user.pk)), + default_token_generator.make_token(user) + ])) + + return super().form_valid(form) \ No newline at end of file