Added a change password page, and linked to it from the account profile page and user menu. Added zxcvbn-based browser-side password strength estimation on the various password setting, re-setting, and changing forms. Added a change password test. Changed ietfauth/urls.py to not use the deprecated string form for views in urlpatterns.
- Legacy-Id: 12798
This commit is contained in:
parent
93efc4470a
commit
7dea44e626
|
@ -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")
|
||||
|
||||
|
|
|
@ -6,11 +6,13 @@ from urlparse import urlsplit
|
|||
from pyquery import PyQuery
|
||||
from unittest import skipIf
|
||||
|
||||
from django.core.urlresolvers import reverse as urlreverse
|
||||
import django.contrib.auth.views
|
||||
from django.core.urlresolvers import reverse as urlreverse
|
||||
from django.contrib.auth.models import User
|
||||
from django.conf import settings
|
||||
|
||||
import debug # pyflakes:ignore
|
||||
|
||||
from ietf.utils.test_utils import TestCase, login_testing_unauthorized, unicontent
|
||||
from ietf.utils.test_data import make_test_data, make_review_data
|
||||
from ietf.utils.mail import outbox, empty_outbox
|
||||
|
@ -399,3 +401,51 @@ class IetfAuthTests(TestCase):
|
|||
update_htpasswd_file("foo", "passwd")
|
||||
self.assertTrue(self.username_in_htpasswd_file("foo"))
|
||||
|
||||
|
||||
def test_change_password(self):
|
||||
|
||||
chpw_url = urlreverse(ietf.ietfauth.views.change_password)
|
||||
prof_url = urlreverse(ietf.ietfauth.views.profile)
|
||||
login_url = urlreverse(django.contrib.auth.views.login)
|
||||
redir_url = '%s?next=%s' % (login_url, chpw_url)
|
||||
|
||||
# get without logging in
|
||||
r = self.client.get(chpw_url)
|
||||
self.assertRedirects(r, redir_url)
|
||||
|
||||
user = User.objects.create(username="someone@example.com", email="someone@example.com")
|
||||
user.set_password("password")
|
||||
user.save()
|
||||
p = Person.objects.create(name="Some One", ascii="Some One", user=user)
|
||||
Email.objects.create(address=user.username, person=p)
|
||||
|
||||
# log in
|
||||
r = self.client.post(redir_url, {"username":user.username, "password":"password"})
|
||||
self.assertRedirects(r, chpw_url)
|
||||
|
||||
# wrong current password
|
||||
r = self.client.post(chpw_url, {"current_password": "fiddlesticks",
|
||||
"new_password": "foobar",
|
||||
"new_password_confirmation": "foobar",
|
||||
})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertFormError(r, 'form', 'current_password', 'Invalid password')
|
||||
|
||||
# mismatching new passwords
|
||||
r = self.client.post(chpw_url, {"current_password": "password",
|
||||
"new_password": "foobar",
|
||||
"new_password_confirmation": "barfoo",
|
||||
})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertFormError(r, 'form', None, "The password confirmation is different than the new password")
|
||||
|
||||
# correct password change
|
||||
r = self.client.post(chpw_url, {"current_password": "password",
|
||||
"new_password": "foobar",
|
||||
"new_password_confirmation": "foobar",
|
||||
})
|
||||
self.assertRedirects(r, prof_url)
|
||||
# refresh user object
|
||||
user = User.objects.get(username="someone@example.com")
|
||||
self.assertTrue(user.check_password(u'foobar'))
|
||||
|
||||
|
|
|
@ -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<auth>[^/]+)/$', views.confirm_new_email),
|
||||
url(r'^create/$', views.create_account),
|
||||
url(r'^create/confirm/(?P<auth>[^/]+)/$', 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<user>[a-z0-9.@]+)/(?P<passwd>.+)$', '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<auth>[^/]+)/$', 'ietf.ietfauth.views.confirm_account'),
|
||||
url(r'^reset/$', 'ietf.ietfauth.views.password_reset'),
|
||||
url(r'^reset/confirm/(?P<auth>[^/]+)/$', 'ietf.ietfauth.views.confirm_password_reset'),
|
||||
url(r'^confirmnewemail/(?P<auth>[^/]+)/$', '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<auth>[^/]+)/$', views.confirm_password_reset),
|
||||
url(r'^review/$', views.review_overview),
|
||||
url(r'^testemail/$', views.test_email),
|
||||
url(r'whitelist/add/?$', views.add_account_whitelist),
|
||||
]
|
||||
|
|
|
@ -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
|
||||
|
@ -340,10 +343,14 @@ def confirm_password_reset(request, auth):
|
|||
else:
|
||||
form = PasswordForm()
|
||||
|
||||
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,
|
||||
'username': username,
|
||||
'user': user,
|
||||
'success': success,
|
||||
'hasher': hasher,
|
||||
})
|
||||
|
||||
def test_email(request):
|
||||
|
@ -465,3 +472,48 @@ 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 = [ e.address for e in Email.objects.filter(person=person, active=True).order_by('-primary','-time') ]
|
||||
user = request.user
|
||||
|
||||
if request.method == 'POST':
|
||||
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)
|
||||
|
||||
send_mail(request, emails, None, "Datatracker password change notification", "registration/password_change_email.txt", {})
|
||||
|
||||
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,
|
||||
'user': user,
|
||||
'success': success,
|
||||
'hasher': hasher,
|
||||
})
|
||||
|
||||
|
||||
|
|
|
@ -704,7 +704,6 @@ 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()
|
||||
|
|
130
ietf/static/ietf/js/password_strength.js
Normal file
130
ietf/static/ietf/js/password_strength.js
Normal file
|
@ -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);
|
|
@ -16,14 +16,17 @@
|
|||
{% else %}
|
||||
{% if user.is_authenticated %}
|
||||
<li><a rel="nofollow" href="/accounts/logout/" >Sign out</a></li>
|
||||
<li><a rel="nofollow" href="/accounts/profile/">Edit profile</a></li>
|
||||
<li><a rel="nofollow" href="/accounts/profile/">Account info</a></li>
|
||||
<li><a rel="nofollow" href="/accounts/password/">Change password</a></li>
|
||||
{% else %}
|
||||
<li><a rel="nofollow" href="/accounts/login/?next={{request.get_full_path|urlencode}}">Sign in</a></li>
|
||||
<li><a rel="nofollow" href="/accounts/reset/">Password reset</a></li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<li><a href="{% url "ietf.ietfauth.views.create_account" %}">{% if request.user.is_authenticated %}Manage account{% else %}New account{% endif %}</a></li>
|
||||
{% if not request.user.is_authenticated %}
|
||||
<li><a href="{% url "ietf.ietfauth.views.create_account" %}">New account</a></li>
|
||||
{% endif %}
|
||||
<li><a href="{%url "ietf.cookies.views.preferences" %}" rel="nofollow">Preferences</a></li>
|
||||
|
||||
{% if user|has_role:"Reviewer" %}
|
||||
|
|
|
@ -3,30 +3,58 @@
|
|||
{% load origin %}
|
||||
|
||||
{% load bootstrap3 %}
|
||||
{% load staticfiles %}
|
||||
|
||||
{% block title %}Change password{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
{{ block.super }}
|
||||
<script type="text/javascript" src="{% static 'zxcvbn/zxcvbn.js' %}"></script>
|
||||
<script type="text/javascript" src="{% static 'ietf/js/password_strength.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
|
||||
{% if success %}
|
||||
<h1>Password change successful</h1>
|
||||
|
||||
<p>Your password has been updated.</p>
|
||||
<a type="a" class="btn btn-primary" href="/accounts/login/" rel="nofollow">Sign in</a>
|
||||
|
||||
<h1>Your password was successfully changed.</h1>
|
||||
{% if not user.is_authenticated %}
|
||||
<a type="a" class="btn btn-primary" href="/accounts/login/" rel="nofollow">Sign in</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<h1>Change password</h1>
|
||||
<div class="row">
|
||||
<div class="col-md-2 col-sm-0"></div>
|
||||
<div class="col-md-8 col-sm-12">
|
||||
<h1>Change password</h1>
|
||||
|
||||
<p>You can change the password below for your user {{ username }} below.</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form form %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form form %}
|
||||
|
||||
{% buttons %}
|
||||
<button type="submit" class="btn btn-primary">Change password</button>
|
||||
{% endbuttons %}
|
||||
</form>
|
||||
{% buttons %}
|
||||
<button type="submit" class="btn btn-primary">Change password</button>
|
||||
{% endbuttons %}
|
||||
</form>
|
||||
|
||||
<div class="help-block">
|
||||
This password form uses the
|
||||
<a href="https://blogs.dropbox.com/tech/2012/04/zxcvbn-realistic-password-strength-estimation/">zxcvbn</a>
|
||||
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.
|
||||
</div>
|
||||
|
||||
<div class="help-block">
|
||||
The datatracker currently uses a <b>{{ hasher.algorithm }}</b>-based
|
||||
password hasher with
|
||||
<b>{% if hasher.iterations %}{{ hasher.iterations }} iterations{% else %}{{ hasher.rounds }} rounds{% endif %}</b>.
|
||||
Calculating offline attack time if password hashes would leak is left
|
||||
as an excercise for the reader.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="col-md-2 col-sm-0"></div>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,11 +1,18 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2015, All Rights Reserved #}
|
||||
{% load origin %}
|
||||
{% load staticfiles %}
|
||||
|
||||
{% load bootstrap3 %}
|
||||
|
||||
{% block title %}Complete account creation{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
{{ block.super }}
|
||||
<script type="text/javascript" src="{% static 'zxcvbn/zxcvbn.js' %}"></script>
|
||||
<script type="text/javascript" src="{% static 'ietf/js/password_strength.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
|
||||
|
|
10
ietf/templates/registration/password_change_email.txt
Normal file
10
ietf/templates/registration/password_change_email.txt
Normal file
|
@ -0,0 +1,10 @@
|
|||
{% autoescape off %}
|
||||
Hello,
|
||||
|
||||
{% filter wordwrap:73 %}The password for your datatracker account was just changed using the password change form. If this was not done by you, please contact the secretariat at ietf-action@ietf.org for assistance.{% endfilter %}
|
||||
|
||||
Best regards,
|
||||
|
||||
The datatracker account manager service
|
||||
(for the IETF Secretariat)
|
||||
{% endautoescape %}
|
Loading…
Reference in a new issue