diff --git a/ietf/bower.json b/ietf/bower.json index 710d76ec7..03d1ecb9b 100644 --- a/ietf/bower.json +++ b/ietf/bower.json @@ -15,7 +15,8 @@ "respond": "~1", "select2": "~3", "select2-bootstrap-css": "~1", - "spin.js": "~2" + "spin.js": "~2", + "zxcvbn": "~4" }, "devDependencies": {}, "overrides": { diff --git a/ietf/ietfauth/forms.py b/ietf/ietfauth/forms.py index 08b0ee9d4..f30f8c103 100644 --- a/ietf/ietfauth/forms.py +++ b/ietf/ietfauth/forms.py @@ -1,4 +1,5 @@ import re +from unidecode import unidecode from django import forms from django.conf import settings @@ -8,7 +9,7 @@ from django.contrib.auth.models import User from django.utils.html import mark_safe from django.core.urlresolvers import reverse as urlreverse -from unidecode import unidecode +from django_password_strength.widgets import PasswordStrengthInput, PasswordConfirmationInput import debug # pyflakes:ignore @@ -31,8 +32,8 @@ class RegistrationForm(forms.Form): class PasswordForm(forms.Form): - password = forms.CharField(widget=forms.PasswordInput) - password_confirmation = forms.CharField(widget=forms.PasswordInput, + password = forms.CharField(widget=PasswordStrengthInput) + password_confirmation = forms.CharField(widget=PasswordConfirmationInput, help_text="Enter the same password as above, for verification.") def clean_password_confirmation(self): @@ -166,3 +167,28 @@ class WhitelistForm(forms.ModelForm): exclude = ['by', 'time' ] +from django import forms + + +class ChangePasswordForm(forms.Form): + current_password = forms.CharField(widget=forms.PasswordInput) + + + new_password = forms.CharField(widget=PasswordStrengthInput) + new_password_confirmation = forms.CharField(widget=PasswordConfirmationInput) + + def __init__(self, user, data=None): + self.user = user + super(ChangePasswordForm, self).__init__(data) + + def clean_current_password(self): + password = self.cleaned_data.get('current_password', None) + if not self.user.check_password(password): + raise ValidationError('Invalid password') + + def clean(self): + new_password = self.cleaned_data.get('new_password', None) + conf_password = self.cleaned_data.get('new_password_confirmation', None) + if not new_password == conf_password: + raise ValidationError("The password confirmation is different than the new password") + diff --git a/ietf/ietfauth/urls.py b/ietf/ietfauth/urls.py index 1bc247554..ee278a4d4 100644 --- a/ietf/ietfauth/urls.py +++ b/ietf/ietfauth/urls.py @@ -3,23 +3,20 @@ from django.conf.urls import url from django.contrib.auth.views import login, logout -from ietf.ietfauth.views import add_account_whitelist +from ietf.ietfauth import views urlpatterns = [ - url(r'^$', 'ietf.ietfauth.views.index'), -# url(r'^login/$', 'ietf.ietfauth.views.ietf_login'), + url(r'^$', views.index), + url(r'^confirmnewemail/(?P[^/]+)/$', views.confirm_new_email), + url(r'^create/$', views.create_account), + url(r'^create/confirm/(?P[^/]+)/$', views.confirm_account), url(r'^login/$', login), url(r'^logout/$', logout), -# url(r'^loggedin/$', 'ietf.ietfauth.views.ietf_loggedin'), -# url(r'^loggedout/$', 'ietf.ietfauth.views.logged_out'), - url(r'^profile/$', 'ietf.ietfauth.views.profile'), -# (r'^login/(?P[a-z0-9.@]+)/(?P.+)$', 'ietf.ietfauth.views.url_login'), - url(r'^testemail/$', 'ietf.ietfauth.views.test_email'), - url(r'^create/$', 'ietf.ietfauth.views.create_account'), - url(r'^create/confirm/(?P[^/]+)/$', 'ietf.ietfauth.views.confirm_account'), - url(r'^reset/$', 'ietf.ietfauth.views.password_reset'), - url(r'^reset/confirm/(?P[^/]+)/$', 'ietf.ietfauth.views.confirm_password_reset'), - url(r'^confirmnewemail/(?P[^/]+)/$', 'ietf.ietfauth.views.confirm_new_email'), - url(r'whitelist/add/?$', add_account_whitelist), - url(r'^review/$', 'ietf.ietfauth.views.review_overview'), + url(r'^password/$', views.change_password), + url(r'^profile/$', views.profile), + url(r'^reset/$', views.password_reset), + url(r'^reset/confirm/(?P[^/]+)/$', views.confirm_password_reset), + url(r'^review/$', views.review_overview), + url(r'^testemail/$', views.test_email), + url(r'whitelist/add/?$', views.add_account_whitelist), ] diff --git a/ietf/ietfauth/views.py b/ietf/ietfauth/views.py index 8b3c18ff1..56bb4f4a1 100644 --- a/ietf/ietfauth/views.py +++ b/ietf/ietfauth/views.py @@ -32,24 +32,27 @@ # Copyright The IETF Trust 2007, All Rights Reserved +import importlib + from datetime import datetime as DateTime, timedelta as TimeDelta, date as Date from collections import defaultdict -from django.conf import settings -from django.http import Http404 #, HttpResponse, HttpResponseRedirect -from django.shortcuts import render, redirect, get_object_or_404 -#from django.contrib.auth import REDIRECT_FIELD_NAME, authenticate, login -from django.contrib.auth.decorators import login_required -#from django.utils.http import urlquote import django.core.signing -from django.contrib.sites.models import Site -from django.contrib.auth.models import User from django import forms +from django.contrib import messages +from django.conf import settings +from django.contrib.auth import update_session_auth_hash +from django.contrib.auth.decorators import login_required +from django.contrib.auth.models import User +from django.contrib.sites.models import Site +from django.core.urlresolvers import reverse as urlreverse +from django.http import Http404, HttpResponseRedirect #, HttpResponse, +from django.shortcuts import render, redirect, get_object_or_404 import debug # pyflakes:ignore from ietf.group.models import Role, Group -from ietf.ietfauth.forms import RegistrationForm, PasswordForm, ResetPasswordForm, TestEmailForm, WhitelistForm +from ietf.ietfauth.forms import RegistrationForm, PasswordForm, ResetPasswordForm, TestEmailForm, WhitelistForm, ChangePasswordForm from ietf.ietfauth.forms import get_person_form, RoleEmailForm, NewEmailForm from ietf.ietfauth.htpasswd import update_htpasswd_file from ietf.ietfauth.utils import role_required @@ -465,3 +468,46 @@ def review_overview(request): 'review_wishes': review_wishes, 'review_wish_form': review_wish_form, }) + +@login_required +def change_password(request): + success = False + person = None + + try: + person = request.user.person + except Person.DoesNotExist: + return render(request, 'registration/missing_person.html') + + emails = Email.objects.filter(person=person, active=True).order_by('-primary','-time').first + + if request.method == 'POST': + user = request.user + form = ChangePasswordForm(user, request.POST) + if form.is_valid(): + new_password = form.cleaned_data["new_password"] + + user.set_password(new_password) + user.save() + # password is also stored in htpasswd file + update_htpasswd_file(user.username, new_password) + # keep the session + update_session_auth_hash(request, user) + + messages.success(request, "Your password was successfully changed") + return HttpResponseRedirect(urlreverse('ietf.ietfauth.views.profile')) + + else: + form = ChangePasswordForm(request.user) + + hlibname, hashername = settings.PASSWORD_HASHERS[0].rsplit('.',1) + + hlib = importlib.import_module(hlibname) + hasher = getattr(hlib, hashername) + return render(request, 'registration/change_password.html', { + 'form': form, + 'success': success, + 'hasher': hasher, + }) + + diff --git a/ietf/ipr/views.py b/ietf/ipr/views.py index 3ec7a0a3d..cf59220de 100644 --- a/ietf/ipr/views.py +++ b/ietf/ipr/views.py @@ -32,6 +32,7 @@ from ietf.ipr.utils import (get_genitive, get_ipr_summary, iprs_from_docs, related_docs) from ietf.message.models import Message from ietf.message.utils import infer_message +from ietf.name.models import IprLicenseTypeName from ietf.person.models import Person from ietf.secr.utils.document import get_rfc_num, is_draft from ietf.utils.draft_search import normalize_draftname @@ -703,6 +704,7 @@ def get_details_tabs(ipr, selected): ('History', urlreverse('ipr_history', kwargs={ 'id': ipr.pk })) ]] +@debug.trace def show(request, id): """View of individual declaration""" ipr = get_object_or_404(IprDisclosureBase, id=id).get_child() @@ -717,6 +719,7 @@ def show(request, id): return render(request, "ipr/details_view.html", { 'ipr': ipr, 'tabs': get_details_tabs(ipr, 'Disclosure'), + 'choices_abc': [ i.desc for i in IprLicenseTypeName.objects.filter(slug__in=['no-license', 'royalty-free', 'reasonable', ]) ], 'updates_iprs': ipr.relatedipr_source_set.all(), 'updated_by_iprs': ipr.relatedipr_target_set.filter(source__state="posted") }) diff --git a/ietf/settings.py b/ietf/settings.py index 7171f3ca1..216f5b6bc 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -54,6 +54,13 @@ ADMINS = ( ('Ryan Cross', 'rcross@amsl.com'), ) +PASSWORD_HASHERS = [ + 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', + 'django.contrib.auth.hashers.PBKDF2PasswordHasher', + 'django.contrib.auth.hashers.SHA1PasswordHasher', + 'django.contrib.auth.hashers.CryptPasswordHasher', +] + ALLOWED_HOSTS = [".ietf.org", ".ietf.org.", "209.208.19.216", "4.31.198.44", ] @@ -296,11 +303,12 @@ INSTALLED_APPS = ( 'django.contrib.staticfiles', # External apps 'bootstrap3', + 'django_markup', + 'django_password_strength', 'djangobwr', 'form_utils', 'tastypie', 'widget_tweaks', - 'django_markup', # IETF apps 'ietf.api', 'ietf.community', @@ -782,7 +790,6 @@ SILENCED_SYSTEM_CHECKS = [ "fields.W342", # Setting unique=True on a ForeignKey has the same effect as using a OneToOneField. ] - # Put the production SECRET_KEY in settings_local.py, and also any other # sensitive or site-specific changes. DO NOT commit settings_local.py to svn. from settings_local import * # pyflakes:ignore pylint: disable=wildcard-import diff --git a/ietf/static/ietf/js/password_strength.js b/ietf/static/ietf/js/password_strength.js new file mode 100644 index 000000000..a1fd393f5 --- /dev/null +++ b/ietf/static/ietf/js/password_strength.js @@ -0,0 +1,130 @@ +// Taken from django-password-strength, with changes to use the bower-managed zxcvbn.js The +// bower-managed zxcvbn.js is kept up-to-date to a larger extent than the copy packaged with +// the django-password-strength component. +(function($, window, document, undefined){ + window.djangoPasswordStrength = { + config: { + passwordClass: 'password_strength', + confirmationClass: 'password_confirmation' + }, + + init: function (config) { + var self = this; + // Setup configuration + if ($.isPlainObject(config)) { + $.extend(self.config, config); + } + + self.initListeners(); + }, + + initListeners: function() { + var self = this; + var body = $('body'); + + $('.' + self.config.passwordClass).on('keyup', function() { + var password_strength_bar = $(this).parent().find('.password_strength_bar'); + var password_strength_info = $(this).parent().find('.password_strength_info'); + + if( $(this).val() ) { + var result = zxcvbn( $(this).val() ); + + if( result.score < 3 ) { + password_strength_bar.removeClass('progress-bar-success').addClass('progress-bar-warning'); + password_strength_info.find('.label').removeClass('hidden'); + } else { + password_strength_bar.removeClass('progress-bar-warning').addClass('progress-bar-success'); + password_strength_info.find('.label').addClass('hidden'); + } + + password_strength_bar.width( ((result.score+1)/5)*100 + '%' ).attr('aria-valuenow', result.score + 1); + // henrik@levkowetz.com -- this is the only changed line: + password_strength_info.find('.password_strength_time').html(result.crack_times_display.online_no_throttling_10_per_second); + password_strength_info.removeClass('hidden'); + } else { + password_strength_bar.removeClass('progress-bar-success').addClass('progress-bar-warning'); + password_strength_bar.width( '0%' ).attr('aria-valuenow', 0); + password_strength_info.addClass('hidden'); + } + self.match_passwords($(this)); + }); + + var timer = null; + $('.' + self.config.confirmationClass).on('keyup', function() { + var password_field; + var confirm_with = $(this).data('confirm-with'); + + if( confirm_with ) { + password_field = $('#' + confirm_with); + } else { + password_field = $('.' + self.config.passwordClass); + } + + if (timer !== null) clearTimeout(timer); + + timer = setTimeout(function(){ + self.match_passwords(password_field); + }, 400); + }); + }, + + display_time: function(seconds) { + var minute = 60; + var hour = minute * 60; + var day = hour * 24; + var month = day * 31; + var year = month * 12; + var century = year * 100; + + // Provide fake gettext for when it is not available + if( typeof gettext !== 'function' ) { gettext = function(text) { return text; }; }; + + if( seconds < minute ) return gettext('only an instant'); + if( seconds < hour) return (1 + Math.ceil(seconds / minute)) + ' ' + gettext('minutes'); + if( seconds < day) return (1 + Math.ceil(seconds / hour)) + ' ' + gettext('hours'); + if( seconds < month) return (1 + Math.ceil(seconds / day)) + ' ' + gettext('days'); + if( seconds < year) return (1 + Math.ceil(seconds / month)) + ' ' + gettext('months'); + if( seconds < century) return (1 + Math.ceil(seconds / year)) + ' ' + gettext('years'); + + return gettext('centuries'); + }, + + match_passwords: function(password_field, confirmation_fields) { + var self = this; + // Optional parameter: if no specific confirmation field is given, check all + if( confirmation_fields === undefined ) { confirmation_fields = $('.' + self.config.confirmationClass) } + if( confirmation_fields === undefined ) { return; } + + var password = password_field.val(); + + confirmation_fields.each(function(index, confirm_field) { + var confirm_value = $(confirm_field).val(); + var confirm_with = $(confirm_field).data('confirm-with'); + + if( confirm_with && confirm_with == password_field.attr('id')) { + if( confirm_value && password ) { + if (confirm_value === password) { + $(confirm_field).parent().find('.password_strength_info').addClass('hidden'); + } else { + $(confirm_field).parent().find('.password_strength_info').removeClass('hidden'); + } + } else { + $(confirm_field).parent().find('.password_strength_info').addClass('hidden'); + } + } + }); + + // If a password field other than our own has been used, add the listener here + if( !password_field.hasClass(self.config.passwordClass) && !password_field.data('password-listener') ) { + password_field.on('keyup', function() { + self.match_passwords($(this)); + }); + password_field.data('password-listener', true); + } + } + }; + + // Call the init for backwards compatibility + djangoPasswordStrength.init(); + +})(jQuery, window, document); diff --git a/ietf/templates/base/menu_user.html b/ietf/templates/base/menu_user.html index 79f1a72c5..697a145b8 100644 --- a/ietf/templates/base/menu_user.html +++ b/ietf/templates/base/menu_user.html @@ -16,14 +16,16 @@ {% else %} {% if user.is_authenticated %}
  • Sign out
  • -
  • Edit profile
  • +
  • Account info
  • {% else %}
  • Sign in
  • Password reset
  • {% endif %} {% endif %} -
  • {% if request.user.is_authenticated %}Manage account{% else %}New account{% endif %}
  • + {% if not request.user.is_authenticated %} +
  • New account
  • + {% endif %}
  • Preferences
  • {% if user|has_role:"Reviewer" %} diff --git a/ietf/templates/ipr/details_view.html b/ietf/templates/ipr/details_view.html index bbac6ef63..3dca63f7f 100644 --- a/ietf/templates/ipr/details_view.html +++ b/ietf/templates/ipr/details_view.html @@ -200,9 +200,19 @@ specification, is as follows(select one licensing declaration option only):

    + {% if ipr.licensing.slug == "provided-later" %} +
    + Possible licencing choices a), b), and c) when Licencing Declaration to be Provided Later: +
      + {% for desc in choices_abc %} +
    • {{ desc}}
    • + {% endfor %} +
    +

    + {% endif %}
    Licensing
    -
    {% if ipr.licensing.slug == "later" %}{{ ipr.licensing.desc|slice:"2:"|slice:":43" }}{% else %}{{ ipr.licensing.desc|slice:"2:" }}{% endif %}
    +
    {% if ipr.licensing.slug == "provided-later" %}{{ ipr.licensing.desc|slice:"2:"|slice:":117" }}){% else %}{{ ipr.licensing.desc|slice:"2:" }}{% endif %}
    Licensing information, comments, notes, or URL for further information
    {{ ipr.licensing_comments|default:"(No information submitted)"|linebreaks }}
    diff --git a/ietf/templates/registration/change_password.html b/ietf/templates/registration/change_password.html index 26321558a..34518b917 100644 --- a/ietf/templates/registration/change_password.html +++ b/ietf/templates/registration/change_password.html @@ -3,30 +3,56 @@ {% load origin %} {% load bootstrap3 %} +{% load staticfiles %} -{% block title %}Change password{% endblock %} +{% block title %}Account creation{% endblock %} + +{% block js %} + {{ block.super }} + + +{% endblock %} {% block content %} {% origin %} {% if success %} -

    Password change successful

    - -

    Your password has been updated.

    - Sign in +

    Your password was successfully changed.

    {% else %} -

    Change password

    +
    +
    +
    +

    Change password

    -

    You can change the password below for your user {{ username }} below.

    -
    - {% csrf_token %} - {% bootstrap_form form %} + + {% csrf_token %} + {% bootstrap_form form %} - {% buttons %} - - {% endbuttons %} -
    + {% buttons %} + + {% endbuttons %} + + +
    + This password change form uses the + zxcvbn + password strength estimator to give an indication of password strength. + The crack times given assume online attack without rate limiting, + at a rate of 10 attempts per second. +
    + +
    + The datatracker currently uses a {{ hasher.algorithm }}-based + password hasher with + {% if hasher.iterations %}{{ hasher.iterations }} iterations{% else %}{{ hasher.rounds }} rounds{% endif %}. + Calculating offline attack time if password hashes wouldleak is left + as an excercise for the reader. +
    + +
    +
    +
    {% endif %} {% endblock %} diff --git a/ietf/templates/registration/create.html b/ietf/templates/registration/create.html index 2c12c9a1e..7bb32242c 100644 --- a/ietf/templates/registration/create.html +++ b/ietf/templates/registration/create.html @@ -18,22 +18,9 @@ {% else %}
    -
    +
    +

    Account creation

    -

    Please enter your email address in order to create a new datatracker account.

    -
    - {% csrf_token %} - {% bootstrap_form form %} - - {% buttons %} - - {% endbuttons %} -
    -
    - -
    -

    Other options

    -

    If you already have an account and want to use a new email address, please go to your account profile page and @@ -49,7 +36,20 @@

    Reset your password

    + +
    + +

    Please enter your email address in order to create your datatracker account.

    +
    + {% csrf_token %} + {% bootstrap_form form %} + + {% buttons %} + + {% endbuttons %} +
    +
    {% endif %} {% endblock %} diff --git a/ietf/templates/registration/edit_profile.html b/ietf/templates/registration/edit_profile.html index 9c4bccad5..44c89d04f 100644 --- a/ietf/templates/registration/edit_profile.html +++ b/ietf/templates/registration/edit_profile.html @@ -22,6 +22,13 @@
    +
    + + +
    +
    diff --git a/ietf/templates/registration/login.html b/ietf/templates/registration/login.html index e64d9423e..732426cad 100644 --- a/ietf/templates/registration/login.html +++ b/ietf/templates/registration/login.html @@ -8,24 +8,27 @@ {% block content %} {% origin %} -

    Sign in

    +
    +
    +

    Sign in

    -
    - {% csrf_token %} - {% bootstrap_form form %} - - {% buttons %} - - - - - -
    - - - Forgot your password? Request a reset. -
    - {% endbuttons %} -
    +
    + {% csrf_token %} + {% bootstrap_form form %} + {% buttons %} + + + + + +
    + + + Forgot your password? Request a reset. +
    + {% endbuttons %} +
    +
    +
    {% endblock %} diff --git a/requirements.txt b/requirements.txt index 8442eaa03..2061988d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,9 +8,11 @@ coverage>=4.0.1,!=4.0.2 decorator>=3.4.0 defusedxml>=0.4.1 # for TastyPie when ussing xml; not a declared dependency Django>=1.9,<1.10 +django-bcrypt>=0.9.2 django-bootstrap3>=7.0 django-formtools>=1.0 # instead of django.contrib.formtools in 1.8 django-markup>=1.1 +django-password-strength>=1.2.1 django-tastypie>=0.13.1 django-widget-tweaks>=1.3 docutils>=0.12 @@ -43,3 +45,4 @@ Unidecode>=0.4.18 #wsgiref>=0.1.2 xml2rfc>=2.5. xym>=0.1.2,!=0.3 +zxcvbn-python>=4.4.14