diff --git a/cloud/endagaweb/celery.py b/cloud/endagaweb/celery.py index 8125f76f..c8731605 100644 --- a/cloud/endagaweb/celery.py +++ b/cloud/endagaweb/celery.py @@ -42,5 +42,9 @@ 'task': 'endagaweb.tasks.usageevents_to_sftp', # Run this at 15:00 UTC (10:00 PDT, 02:00 Papua time) 'schedule': crontab(minute=0, hour=17), + }, 'block-user': { + 'task': 'endagaweb.tasks.block_user', + # Run this at 15:00 UTC (10:00 PDT, 02:00 Papua time) + 'schedule': crontab(minute=0, hour=17), } }) diff --git a/cloud/endagaweb/forms/dashboard_forms.py b/cloud/endagaweb/forms/dashboard_forms.py index 2f9a6ddc..50749e54 100644 --- a/cloud/endagaweb/forms/dashboard_forms.py +++ b/cloud/endagaweb/forms/dashboard_forms.py @@ -23,6 +23,7 @@ from ccm.common.currency import CURRENCIES from endagaweb import models from endagaweb.templatetags import apptags +from django.contrib.auth import password_validation class UpdateContactForm(forms.Form): @@ -153,6 +154,10 @@ def __init__(self, *args, **kwargs): class ChangePasswordForm(PasswordChangeForm): """Change password form visible on user profile page.""" + """Updated for password validation. """ + + error_message = '' + def __init__(self, *args, **kwargs): super(ChangePasswordForm, self).__init__(*args, **kwargs) self.helper = FormHelper() @@ -160,8 +165,26 @@ def __init__(self, *args, **kwargs): self.helper.form_method = 'post' self.helper.form_action = '/account/password/change' self.helper.form_class = 'profile-form' + self.error_message = '' self.helper.add_input(Submit('submit', 'Save')) + def clean_password1(self): + old_password = self.cleaned_data.get("old_password") + new_password1 = self.cleaned_data.get("new_password1") + if new_password1 and old_password and old_password == new_password1: + self.error_message = 'Error: new password must not be old password.' + raise forms.ValidationError(self.error_message) + password_validation.validate_password(new_password1) + return new_password1 + + def clean_new_password2(self): + password1 = self.cleaned_data.get('new_password1') + password2 = self.cleaned_data.get('new_password2') + if password1 and password2: + if password1 != password2: + self.error_message = 'Error:conform password does not match.' + raise forms.ValidationError(self.error_message) + return password2 class NotifyEmailsForm(forms.Form): notify_emails = forms.CharField(required=False, label="", diff --git a/cloud/endagaweb/models.py b/cloud/endagaweb/models.py index d2429f9d..133668c2 100644 --- a/cloud/endagaweb/models.py +++ b/cloud/endagaweb/models.py @@ -77,7 +77,10 @@ class UserProfile(models.Model): # because a user may have permissions on other Network instances. # For example to get a list of networks the user can view: # >>> get_objects_for_user(user_profile.user, 'view_network', klass=Network) - network = models.ForeignKey('Network', null=True, on_delete=models.SET_NULL) + network = models.ForeignKey('Network', null=True, + on_delete=models.SET_NULL) + # Added for Password Expiry + last_pwd_update = models.DateTimeField(auto_now=True) def __str__(self): return "%s's profile" % self.user @@ -1466,8 +1469,7 @@ class ConfigurationKey(models.Model): Can be associated with many things. """ bts = models.ForeignKey(BTS, null=True, blank=True, on_delete=models.CASCADE) - network = models.ForeignKey(Network, null=True, blank=True, - on_delete=models.CASCADE) + network = models.ForeignKey(Network, null=True, blank=True, on_delete=models.CASCADE) category = models.TextField() # "endaga", "openbts", etc.. key = models.TextField() value = models.TextField() diff --git a/cloud/endagaweb/settings/prod.py b/cloud/endagaweb/settings/prod.py index 653f2f0e..045786f4 100644 --- a/cloud/endagaweb/settings/prod.py +++ b/cloud/endagaweb/settings/prod.py @@ -277,6 +277,10 @@ # Enable/disable billing for networks. If false, we ignore what's in the # network's account balance. 'NW_BILLING': os.environ.get("NW_BILLING", "True").lower() == "true", + # Password Expiration day + 'PASSWORD_EXPIRED_DAY': 90, + # Password Expiry Alert + 'PASSSWORD_EXPIRED_LAST_SEVEN_DAYS': 83, } STRIPE_API_KEY = os.environ.get("STRIPE_API_KEY", @@ -317,3 +321,11 @@ # Security middleware settings SECURE_CONTENT_TYPE_NOSNIFF = True + +# Password validation +AUTH_PASSWORD_VALIDATORS = [ + + { 'NAME': 'endagaweb.util.custom_password_validators.CustomPasswortValidator', + }, + +] \ No newline at end of file diff --git a/cloud/endagaweb/tasks.py b/cloud/endagaweb/tasks.py index 405ca698..18dc1f12 100644 --- a/cloud/endagaweb/tasks.py +++ b/cloud/endagaweb/tasks.py @@ -41,7 +41,7 @@ from endagaweb.models import Subscriber from endagaweb.models import UsageEvent from endagaweb.models import SystemEvent -from endagaweb.models import TimeseriesStat +from endagaweb.models import TimeseriesStat, UserProfile from endagaweb.ic_providers.nexmo import NexmoProvider @@ -439,3 +439,17 @@ def req_bts_log(self, obj, retry_delay=60*10, max_retries=432): raise finally: obj.save() + +@app.task(bind=True) +def block_user(self): + """ Block User if User password is not updated + for last number of days which is configured in a settings . + """ + password_expired_duration = (django.utils.timezone.now() - + datetime.timedelta( + days=settings.ENDAGA['PASSWORD_EXPIRED_DAY'])) + user_profiles = UserProfile.objects.filter(last_pwd_update__lte=password_expired_duration) + for user_profile in user_profiles: + user_profile.user.is_active = False + print '%s user is Blocked!' % user_profile.user.username + user_profile.user.save() diff --git a/cloud/endagaweb/templates/dashboard/index.html b/cloud/endagaweb/templates/dashboard/index.html index 417197b9..45fb034b 100644 --- a/cloud/endagaweb/templates/dashboard/index.html +++ b/cloud/endagaweb/templates/dashboard/index.html @@ -23,6 +23,56 @@ display: none; } + + + + + + +{% if messages %} + + + +{% endif %} + + + {% endblock %} diff --git a/cloud/endagaweb/templates/dashboard/password_change.html b/cloud/endagaweb/templates/dashboard/password_change.html new file mode 100644 index 00000000..d79e4a34 --- /dev/null +++ b/cloud/endagaweb/templates/dashboard/password_change.html @@ -0,0 +1,37 @@ +{% extends "dashboard/layout.html" %} +{% comment %} +Copyright (c) 2016-present, Facebook, Inc. +All rights reserved. + +This source code is licensed under the BSD-style license found in the +LICENSE file in the root directory of this source tree. An additional grant +of patent rights can be found in the PATENTS file in the same directory. +{% endcomment %} +{% load apptags %} +{% load crispy_forms_tags %} + +{% block title %} {% tmpl_const "SITENAME" %} | Monitor Usage, Pay Bills {% endblock %} + +{% block content %} + +
+ +
+ +
+ +
+

Change Password

+ {% crispy change_pass_form %} + {% for message in messages %} + {% if 'password' in message.tags %} +
{{ message }}
+ {% endif %} + {% endfor %} +
+
+ + +{% endblock %} diff --git a/cloud/endagaweb/tests/test_user_password.py b/cloud/endagaweb/tests/test_user_password.py new file mode 100644 index 00000000..716c0ebe --- /dev/null +++ b/cloud/endagaweb/tests/test_user_password.py @@ -0,0 +1,223 @@ +"""Tests for models.Users password + +Copyright (c) 2016-present, Facebook, Inc. +All rights reserved. + +This source code is licensed under the BSD-style license found in the +LICENSE file in the root directory of this source tree. An additional grant +of patent rights can be found in the PATENTS file in the same directory. +""" + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals +from django import test +from django.test import TestCase +from endagaweb import models + + +class TestBase(TestCase): + + @classmethod + def setUpClass(cls): + cls.username = 'testuser' + cls.password = 'testuser_abad' + cls.user = models.User(username=cls.username, email='y@l.com') + cls.user.set_password(cls.password) + cls.user.save() + cls.error_tag = 'password alert alert-danger error' + cls.success_tag = 'password alert alert-success success' + # Create a test client. + cls.client = test.Client() + + @classmethod + def tearDownClass(cls): + cls.user.delete() + + def tearDown(self): + self.logout() + + def login(self): + """Log the client in.""" + data = { + 'email': self.username, + 'password': self.password, + } + self.client.post('/auth/', data) + + def logout(self): + """Log the client out.""" + self.client.get('/logout') + + @classmethod + def getmessage(cls, response): + """Helper method to return message from response """ + for c in response.context: + message = [m for m in c.get('messages')][0] + if message: + return message + + +class UserPasswordStrengthTests(TestBase): + """Testing strength of new password.""" + + def test_old_password(self): + """validate invalid old password.""" + self.login() + data = { + 'old_password': 'testuser_abads', + 'new_password1': 'Admin_123', + 'new_password2': 'Admin_123' + } + response = self.client.post('/account/password/change/', data , + follow=True, HTTP_REFERER='/password/change') + message = self.getmessage(response) + self.assertEqual(message.tags, self.error_tag) + + def test_new_password_case1(self): + """validate new password must contain 8 length.""" + self.login() + data = { + 'old_password': 'testuser_abad', + 'new_password1': 'test_12', + 'new_password2': 'test_12' + } + response = self.client.post('/account/password/change/', data, + follow=True,HTTP_REFERER='/password/change') + message = self.getmessage(response) + self.assertEqual(message.tags, self.error_tag) + + def test_new_password_case2(self): + """validate new password must contain alphanumeric.""" + self.login() + data = { + 'old_password': 'testuser_abad', + 'new_password1': 'user_abcde', + 'new_password2': 'user_abcde' + } + response = self.client.post('/account/password/change/', data, + follow=True, HTTP_REFERER='/password/change') + message = self.getmessage(response) + self.assertEqual(message.tags, self.error_tag) + + def test_new_password_case3(self): + """validate new password must contain alphanumeric.""" + self.login() + data = { + 'old_password': 'testuser_abad', + 'new_password1': '1988@1999', + 'new_password2': '1988@1999' + } + response = self.client.post('/account/password/change/', data, + follow=True, HTTP_REFERER='/password/change') + message = self.getmessage(response) + self.assertEqual(message.tags,self.error_tag) + + + def test_new_password_case4(self): + """validate new password must contain special character.""" + self.login() + data = { + 'old_password': 'testuser_abad', + 'new_password1': '1988abcd', + 'new_password2': '1988abcd' + } + response = self.client.post('/account/password/change/', data, + follow=True, HTTP_REFERER='/password/change') + message = self.getmessage(response) + self.assertEqual(message.tags, self.error_tag) + + def test_new_password_case5(self): + """validate new password must be minimum length of 8 character.""" + self.login() + data = { + 'old_password': 'testuser_abad', + 'new_password1': 'sinha_1', + 'new_password2': 'sinha_1' + } + response = self.client.post('/account/password/change', data, + follow=True, HTTP_REFERER='/password/change') + message = self.getmessage(response) + self.assertEqual(message.tags, self.error_tag) + + def test_new_password_case6(self): + """validate new password must have alhanumeric and special character.""" + self.login() + data = { + 'old_password': 'testuser_abad', + 'new_password1': 'userA_183', + 'new_password2': 'userA_183' + } + response = self.client.post('/account/password/change', data, + follow=True, HTTP_REFERER='/password/change') + message = self.getmessage(response) + self.assertEqual(message.tags, self.success_tag) + + def test_redirect_url_for_dashbboard_profile(self): + """validate if change password page come from /dashboard/profile , + redirect to /dashboard/profile.""" + self.login() + data = { + 'old_password': 'testuser_abad', + 'new_password1': 'userA_184', + 'new_password2': 'userA_184' + } + response = self.client.post('/account/password/change/', data, + HTTP_REFERER='/dashboard/profile') + self.assertEqual(response.url, '/dashboard/profile') + self.assertEqual(response.status_code,302) + + def test_redirect_url_for_password_change(self): + """validate if change password page come from /password/change , + redirect to /password/change.""" + self.login() + data = { + 'old_password': 'testuser_abad', + 'new_password1': 'userA%185', + 'new_password2': 'userA%184' + } + response = self.client.post('/account/password/change', data, + + HTTP_REFERER='/password/change') + self.assertEqual(response.url, '/password/change') + self.assertEqual(response.status_code, 302) + + def test_new_password_case7(self): + """validate new password with special character.""" + self.login() + data = { + 'old_password': 'testuser_abad', + 'new_password1': '185@auser1', + 'new_password2': '185@auser1' + } + response = self.client.post('/account/password/change', data,follow=True, + HTTP_REFERER='/password/change' ) + message = self.getmessage(response) + self.assertEqual(message.tags, self.success_tag) + + def test_new_password_case8(self): + """validate new password with special character.""" + self.login() + data = { + 'old_password': 'testuser_abad', + 'new_password1': 'User*#4As', + 'new_password2': 'User*#4As' + } + response = self.client.post('/account/password/change', data,follow=True, + HTTP_REFERER='/password/change' ) + message = self.getmessage(response) + self.assertEqual(message.tags, self.success_tag) + + def test_new_password_case9(self): + """validate new password with special character.""" + self.login() + data = { + 'old_password': 'testuser_abad', + 'new_password1': '4as8trtaabb^', + 'new_password2': '4as8trtaabb^' + } + response = self.client.post('/account/password/change', data,follow=True, + HTTP_REFERER='/password/change' ) + message = self.getmessage(response) + self.assertEqual(message.tags, self.success_tag) diff --git a/cloud/endagaweb/urls.py b/cloud/endagaweb/urls.py index 4c7d2469..241541cd 100644 --- a/cloud/endagaweb/urls.py +++ b/cloud/endagaweb/urls.py @@ -83,6 +83,8 @@ url(r'^account/update', endagaweb.views.user.update_contact), url(r'^account/', endagaweb.views.dashboard.dashboard_view), url(r'^logout/$', django.contrib.auth.views.logout, {'next_page': '/'}), + # Added for ExpiredPassword + url(r'^password/change', endagaweb.views.user.change_expired_password), # Dashboard. url(r'^dashboard/card', endagaweb.views.dashboard.addcard), diff --git a/cloud/endagaweb/util/custom_password_validators.py b/cloud/endagaweb/util/custom_password_validators.py new file mode 100644 index 00000000..c6a2e4e0 --- /dev/null +++ b/cloud/endagaweb/util/custom_password_validators.py @@ -0,0 +1,24 @@ +from django.core.exceptions import ValidationError +from django.utils.translation import ugettext as _ + +class CustomPasswortValidator(object): + + def __init__(self, min_length=1): + self.min_length = min_length + + def validate(self, password, user=None): + special_characters = "[~\!@#\$%\^&\*\(\)_\+{}\":;'\[\]]" + min_charcter_length =8; + if len(password) < min_charcter_length: + raise ValidationError(_('Password must be at least {0} characters ' + 'long.').format(min_charcter_length)) + + if not any(char.isdigit() for char in password): + raise ValidationError(_('Password must contain at least %(min_length)d digit.') % {'min_length': self.min_length}) + if not any(char.isalpha() for char in password): + raise ValidationError(_('Password must contain at least %(min_length)d letter.') % {'min_length': self.min_length}) + if not any(char in special_characters for char in password): + raise ValidationError(_('Password must contain at least %(min_length)d special character.') % {'min_length': self.min_length}) + + def get_help_text(self): + return 'Password must contain atlest 8 characters,contains alphanumeric and one special character.' \ No newline at end of file diff --git a/cloud/endagaweb/views/user.py b/cloud/endagaweb/views/user.py index 8d7fc776..d89f8e9c 100644 --- a/cloud/endagaweb/views/user.py +++ b/cloud/endagaweb/views/user.py @@ -27,6 +27,13 @@ from endagaweb.models import UserProfile import logging +from django.utils import timezone +import urlparse +import re +from guardian.shortcuts import get_objects_for_user +from endagaweb.forms import dashboard_forms as dform +from endagaweb import models +from django.core import exceptions logger = logging.getLogger('endagaweb') @@ -102,12 +109,30 @@ def auth_and_login(request): """Handles POSTed credentials for login.""" user = authenticate(username=request.POST['email'], password=request.POST['password']) - if user: - login(request, user) - next_url = '/dashboard' - if 'next' in request.POST and request.POST['next']: - next_url = request.POST['next'] - return redirect(next_url) + if user is not None: + if user.is_active: + login(request, user) + user = User.objects.get(username=user) + today = timezone.now() + user_profile = UserProfile.objects.get(user=user) + next_url = '/dashboard' + if 'next' in request.POST and request.POST['next']: + next_url = request.POST['next'] + if (today - user_profile.last_pwd_update).days >= \ + settings.ENDAGA['PASSSWORD_EXPIRED_LAST_SEVEN_DAYS']: + password_expired_day_left = str(settings.ENDAGA['PASSWORD_EXPIRED_DAY'] + - (today - user_profile.last_pwd_update).days) + text = '%s, your account will be blocked in next %s days unless' \ + ' change your password' %(user, password_expired_day_left) + messages.error(request, text) + return redirect(next_url) + else: + return redirect(next_url) + else: + # Notification, if blocked user is trying to log in + text = "This user is blocked. Please contact admin." + messages.error(request, text) + return redirect('/login/') else: text = "Sorry, that email / password combination is not valid." messages.error(request, text) @@ -123,31 +148,69 @@ def change_password(request): required_params = ('old_password', 'new_password1', 'new_password2') if not all([param in request.POST for param in required_params]): return HttpResponseBadRequest() - # Validate - redirect_url = '/dashboard/profile' + # Validate url for redirect + if urlparse.urlparse(request.META['HTTP_REFERER'] + ).path != '/dashboard/profile': + redirect_url = '/password/change' + else: + redirect_url = '/dashboard/profile' if not request.user.check_password(request.POST['old_password']): text = 'Error: old password is incorrect.' tags = 'password alert alert-danger' messages.error(request, text, extra_tags=tags) return redirect(redirect_url) - if request.POST['new_password1'] != request.POST['new_password2']: - text = 'Error: new passwords do not match.' - tags = 'password alert alert-danger' - messages.error(request, text, extra_tags=tags) - return redirect(redirect_url) - if request.POST['new_password1'] == '': - text = 'Error: new password is not valid.' + try: + form = dform.ChangePasswordForm(user=request.user, data=request.POST) + if form.is_valid(): + new_password1 =form.clean_password1() + form.save() + request.user.set_password(new_password1) + user_profile = UserProfile.objects.get(user=request.user) + user_profile.last_pwd_update = timezone.now() + user_profile.save() + request.user.save() + text = 'Password changed successfully.' + tags = 'password alert alert-success' + messages.success(request, text, extra_tags=tags) + if urlparse.urlparse(request.META['HTTP_REFERER'] + ).path != '/dashboard/profile': + redirect_url = '/dashboard' + return redirect(redirect_url) + else: + return redirect(redirect_url) + else: + """if form is invalid in scenario if conform password not match with + new password, so firstly validate new_password strength and raise execption + if password strength is success then give error Error:conform password does + not match by default djnago called clean_password2().""" + + form.clean_password1() + tags = 'password alert alert-danger' + messages.error(request, form.error_message, extra_tags=tags) + return redirect(redirect_url) + except exceptions.ValidationError as e: tags = 'password alert alert-danger' - messages.error(request, text, extra_tags=tags) + messages.error(request, ''.join(e.messages), extra_tags=tags) return redirect(redirect_url) - # Everything checks out, change the password. - request.user.set_password(request.POST['new_password1']) - request.user.save() - text = 'Password changed successfully.' - tags = 'password alert alert-success' - messages.success(request, text, extra_tags=tags) - return redirect(redirect_url) +@login_required(login_url='/login/') +def change_expired_password(request): + """Render password change template to change + password + """ + user_profile = UserProfile.objects.get(user=request.user) + network = user_profile.network + context = { + 'networks': get_objects_for_user(request.user, 'view_network', klass=models.Network), + 'user_profile': user_profile, + 'network': network, + 'user_profile': user_profile, + 'change_pass_form': dform.ChangePasswordForm(request.user), + + } + template = get_template("dashboard/password_change.html") + html = template.render(context, request) + return HttpResponse(html) @login_required(login_url='/login/') def update_contact(request): @@ -219,3 +282,4 @@ def update_notify_numbers(request): return redirect("/dashboard/profile") return HttpResponseBadRequest() +