diff --git a/ietf/ietfauth/forms.py b/ietf/ietfauth/forms.py index b5af137ea..59c9cc0e6 100644 --- a/ietf/ietfauth/forms.py +++ b/ietf/ietfauth/forms.py @@ -1,235 +1,96 @@ -import datetime -import hashlib -import subprocess +import re from django import forms from django.forms import ModelForm -from django.conf import settings +from django.db import models from django.contrib.auth.models import User -from django.contrib.sites.models import Site -from django.utils.translation import ugettext_lazy as _ +from django.utils.html import mark_safe +from django.core.urlresolvers import reverse as urlreverse -from ietf.utils.mail import send_mail -from ietf.person.models import Person, Email, Alias -from ietf.group.models import Role +from ietf.person.models import Person, Email class RegistrationForm(forms.Form): - email = forms.EmailField(label="Your email (lowercase)") - realm = 'IETF' - expire = 3 - - def save(self, *args, **kwargs): - # why is there a save when it doesn't save? - self.send_email() - return True - - def send_email(self): - domain = Site.objects.get_current().domain - subject = 'Confirm registration at %s' % domain - from_email = settings.DEFAULT_FROM_EMAIL - to_email = self.cleaned_data['email'] - today = datetime.date.today().strftime('%Y%m%d') - auth = hashlib.md5('%s%s%s%s' % (settings.SECRET_KEY, today, to_email, self.realm)).hexdigest() - context = { - 'domain': domain, - 'today': today, - 'realm': self.realm, - 'auth': auth, - 'username': to_email, - 'expire': settings.DAYS_TO_EXPIRE_REGISTRATION_LINK, - } - send_mail(self.request, to_email, from_email, subject, 'registration/creation_email.txt', context) def clean_email(self): email = self.cleaned_data.get('email', '') if not email: return email if email.lower() != email: - raise forms.ValidationError(_('The supplied address contained uppercase letters. Please use a lowercase email address.')) - if User.objects.filter(username=email).count(): - raise forms.ValidationError(_('An account with the email address you provided already exists.')) - return email - - -class RecoverPasswordForm(RegistrationForm): - - realm = 'IETF' - - def send_email(self): - domain = Site.objects.get_current().domain - subject = 'Password reset at %s' % domain - from_email = settings.DEFAULT_FROM_EMAIL - today = datetime.date.today().strftime('%Y%m%d') - to_email = self.cleaned_data['email'] - today = datetime.date.today().strftime('%Y%m%d') - auth = hashlib.md5('%s%s%s%s' % (settings.SECRET_KEY, today, to_email, self.realm)).hexdigest() - context = { - 'domain': domain, - 'today': today, - 'realm': self.realm, - 'auth': auth, - 'username': to_email, - 'expire': settings.DAYS_TO_EXPIRE_REGISTRATION_LINK, - } - send_mail(self.request, to_email, from_email, subject, 'registration/password_reset_email.txt', context) - - def clean_email(self): - email = self.cleaned_data.get('email', '') + raise forms.ValidationError('The supplied address contained uppercase letters. Please use a lowercase email address.') + if User.objects.filter(username=email).exists(): + raise forms.ValidationError('An account with the email address you provided already exists.') return email class PasswordForm(forms.Form): + password = forms.CharField(widget=forms.PasswordInput) + password_confirmation = forms.CharField(widget=forms.PasswordInput, + help_text="Enter the same password as above, for verification.") - password1 = forms.CharField(label=_("Password"), widget=forms.PasswordInput) - password2 = forms.CharField(label=_("Password confirmation"), widget=forms.PasswordInput, - help_text=_("Enter the same password as above, for verification.")) + def clean_password_confirmation(self): + password = self.cleaned_data.get("password", "") + password_confirmation = self.cleaned_data["password_confirmation"] + if password != password_confirmation: + raise forms.ValidationError("The two password fields didn't match.") + return password_confirmation - def __init__(self, *args, **kwargs): - self.username = kwargs.pop('username') - self.update_user = User.objects.filter(username=self.username).count() > 0 - super(PasswordForm, self).__init__(*args, **kwargs) - def clean_password2(self): - password1 = self.cleaned_data.get("password1", "") - password2 = self.cleaned_data["password2"] - if password1 != password2: - raise forms.ValidationError(_("The two password fields didn't match.")) - return password2 +def ascii_cleaner(supposedly_ascii): + outside_printable_ascii_pattern = r'[^\x20-\x7F]' + if re.search(outside_printable_ascii_pattern, supposedly_ascii): + raise forms.ValidationError("Please only enter ASCII characters.") + return supposedly_ascii - def get_password(self): - return self.cleaned_data.get('password1') +class PersonForm(ModelForm): + class Meta: + model = Person + exclude = ('time', 'user') - def create_user(self): - user = User.objects.create(username=self.username, - email=self.username) - email = Email.objects.filter(address=self.username) - person = None - if email.count(): - email = email[0] - if email.person: - person = email.person - else: - email = None - if not person: - person = Person.objects.create(user=user, - name=self.username, - ascii=self.username) - if not email: - email = Email.objects.create(address=self.username, - person=person) - email.person = person - email.save() - person.user = user - person.save() - return user + def clean_ascii(self): + return ascii_cleaner(self.cleaned_data.get("ascii") or u"") - def get_user(self): - return User.objects.get(username=self.username) + def clean_ascii_short(self): + return ascii_cleaner(self.cleaned_data.get("ascii_short") or u"") - def save_password_file(self): - if getattr(settings, 'USE_PYTHON_HTDIGEST', None): - pass_file = settings.HTPASSWD_FILE - realm = settings.HTDIGEST_REALM - password = self.get_password() - username = self.username - prefix = '%s:%s:' % (username, realm) - key = hashlib.md5(prefix + password).hexdigest() - f = open(pass_file, 'r+') - pos = f.tell() - line = f.readline() - while line: - if line.startswith(prefix): - break - pos=f.tell() - line = f.readline() - f.seek(pos) - f.write('%s%s\n' % (prefix, key)) - f.close() - else: - p = subprocess.Popen([settings.HTPASSWD_COMMAND, "-b", settings.HTPASSWD_FILE, self.username, self.get_password()], stdout=subprocess.PIPE, stderr=subprocess.PIPE) - stdout, stderr = p.communicate() - def save(self): - if self.update_user: - user = self.get_user() - else: - user = self.create_user() - user.set_password(self.get_password()) - user.save() - self.save_password_file() - return user +class NewEmailForm(forms.Form): + new_email = forms.EmailField(label="New email address", required=False) + + def clean_new_email(self): + email = self.cleaned_data.get("new_email", "") + if email: + existing = Email.objects.filter(address=email).first() + if existing: + raise forms.ValidationError("Email address '%s' is already assigned to account '%s' (%s)" % (existing, existing.person and existing.person.user, existing.person)) + return email + + +class RoleEmailForm(forms.Form): + email = forms.ModelChoiceField(label="Role email", queryset=Email.objects.all()) + + def __init__(self, role, *args, **kwargs): + super(RoleEmailForm, self).__init__(*args, **kwargs) + + f = self.fields["email"] + f.label = u"%s in %s" % (role.name, role.group.acronym.upper()) + f.help_text = u"Email to use for %s role in %s" % (role.name, role.group.name) + f.queryset = f.queryset.filter(models.Q(person=role.person_id) | models.Q(role=role)) + f.initial = role.email_id + f.choices = [(e.pk, e.address if e.active else u"({})".format(e.address)) for e in f.queryset] + + +class ResetPasswordForm(forms.Form): + email = forms.EmailField(label="Your email (lowercase)") + + def clean_email(self): + email = self.cleaned_data["email"] + if not User.objects.filter(username=email).exists(): + raise forms.ValidationError(mark_safe("Didn't find a matching account. If you don't have an account yet, you can create one.".format(urlreverse("create_account")))) + return email + class TestEmailForm(forms.Form): email = forms.EmailField(required=False) -class PersonForm(ModelForm): - request = None - new_emails = [] - class Meta: - model = Person - exclude = ('time','user') - - def confirm_address(self,email): - person = self.instance - domain = Site.objects.get_current().domain - user = person.user - if len(email) == 0: - return - subject = 'Confirm email address for %s' % person.name - from_email = settings.DEFAULT_FROM_EMAIL - to_email = email - today = datetime.date.today().strftime('%Y%m%d') - auth = hashlib.md5('%s%s%s%s' % (settings.SECRET_KEY, today, to_email, user)).hexdigest() - context = { - 'today': today, - 'domain': domain, - 'user': user, - 'email': email, - 'expire': settings.DAYS_TO_EXPIRE_REGISTRATION_LINK, - 'auth': auth, - } - send_mail(self.request, to_email, from_email, subject, 'registration/add_email_email.txt', context) - - def save(self, force_insert=False, force_update=False, commit=True): - m = super(PersonForm, self).save(commit=False) - self.new_emails = [v for k,v in self.data.items() if k[:10] == u'new_email_' and u'@' in v] - - for email in self.new_emails: - self.confirm_address(email) - - # Process email active flags - emails = Email.objects.filter(person=self.instance) - for email in emails: - email.active = self.data.__contains__(email.address) - if commit: - email.save() - - # Process email for roles - for k,v in self.data.items(): - if k[:11] == u'role_email_': - role = Role.objects.get(id=k[11:]) - email = Email.objects.get(address = v) - role.email = email - if commit: - role.save() - - # Make sure the alias table contains any new and/or old names. - old_names = set([x.name for x in Alias.objects.filter(person=self.instance)]) - curr_names = set([x for x in [self.instance.name, - self.instance.ascii, - self.instance.ascii_short, - self.data['name'], - self.data['ascii'], - self.data['ascii_short']] if len(x)]) - new_names = curr_names - old_names - for name in new_names: - alias = Alias(person=self.instance,name=name) - if commit: - alias.save() - - if commit: - m.save() - return m - diff --git a/ietf/ietfauth/htpasswd.py b/ietf/ietfauth/htpasswd.py new file mode 100644 index 000000000..bebaa284f --- /dev/null +++ b/ietf/ietfauth/htpasswd.py @@ -0,0 +1,24 @@ +import subprocess, hashlib + +from django.conf import settings + +def save_htpasswd_file(username, password): + if getattr(settings, 'USE_PYTHON_HTDIGEST', None): + pass_file = settings.HTPASSWD_FILE + realm = settings.HTDIGEST_REALM + prefix = '%s:%s:' % (username, realm) + key = hashlib.md5(prefix + password).hexdigest() + f = open(pass_file, 'r+') + pos = f.tell() + line = f.readline() + while line: + if line.startswith(prefix): + break + pos=f.tell() + line = f.readline() + f.seek(pos) + f.write('%s%s\n' % (prefix, key)) + f.close() + else: + p = subprocess.Popen([settings.HTPASSWD_COMMAND, "-b", settings.HTPASSWD_FILE, username, password], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + stdout, stderr = p.communicate() diff --git a/ietf/ietfauth/tests.py b/ietf/ietfauth/tests.py index a613c9b53..633dc8320 100644 --- a/ietf/ietfauth/tests.py +++ b/ietf/ietfauth/tests.py @@ -1,83 +1,261 @@ -# Copyright (C) 2009 Nokia Corporation and/or its subsidiary(-ies). -# All rights reserved. Contact: Pasi Eronen -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions -# are met: -# -# * Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. -# -# * Redistributions in binary form must reproduce the above -# copyright notice, this list of conditions and the following -# disclaimer in the documentation and/or other materials provided -# with the distribution. -# -# * Neither the name of the Nokia Corporation and/or its -# subsidiary(-ies) nor the names of its contributors may be used -# to endorse or promote products derived from this software -# without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - +# -*- coding: utf-8 -*- +import os, shutil from urlparse import urlsplit -from django.core.urlresolvers import reverse as urlreverse +from pyquery import PyQuery -from ietf.utils.test_utils import TestCase, login_testing_unauthorized, unicontent +from django.core.urlresolvers import reverse as urlreverse +from django.contrib.auth.models import User +from django.conf import settings + +from ietf.utils.test_utils import TestCase, login_testing_unauthorized from ietf.utils.test_data import make_test_data +from ietf.utils.mail import outbox, empty_outbox +from ietf.person.models import Person, Email +from ietf.group.models import Group, Role, RoleName class IetfAuthTests(TestCase): + def setUp(self): + self.saved_htpasswd_file = settings.HTPASSWD_FILE + self.htpasswd_dir = os.path.abspath("tmp-htpasswd-dir") + os.mkdir(self.htpasswd_dir) + settings.HTPASSWD_FILE = os.path.join(self.htpasswd_dir, "htpasswd") + open(settings.HTPASSWD_FILE, 'a').close() # create empty file + self.saved_htdigest_realm = getattr(settings, "HTDIGEST_REALM", None) + settings.HTDIGEST_REALM = "test-realm" + + def tearDown(self): + shutil.rmtree(self.htpasswd_dir) + settings.HTPASSWD_FILE = self.saved_htpasswd_file + settings.HTDIGEST_REALM = self.saved_htdigest_realm + def test_index(self): self.assertEqual(self.client.get(urlreverse("ietf.ietfauth.views.index")).status_code, 200) - def test_login(self): + def test_login_and_logout(self): make_test_data() # try logging in without a next - r = self.client.get('/accounts/login/') + r = self.client.get(urlreverse("account_login")) self.assertEqual(r.status_code, 200) - r = self.client.post('/accounts/login/', {"username":"plain", "password":"plain+password"}) + r = self.client.post(urlreverse("account_login"), {"username":"plain", "password":"plain+password"}) self.assertEqual(r.status_code, 302) self.assertEqual(urlsplit(r["Location"])[2], "/accounts/profile/") # try logging out - r = self.client.get('/accounts/logout/') + r = self.client.get(urlreverse("account_logout")) self.assertEqual(r.status_code, 200) - r = self.client.get('/accounts/profile/') + r = self.client.get(urlreverse("account_profile")) self.assertEqual(r.status_code, 302) self.assertEqual(urlsplit(r["Location"])[2], "/accounts/login/") # try logging in with a next - r = self.client.post('/accounts/login/?next=/foobar', {"username":"plain", "password":"plain+password"}) + r = self.client.post(urlreverse("account_login") + "?next=/foobar", {"username":"plain", "password":"plain+password"}) self.assertEqual(r.status_code, 302) self.assertEqual(urlsplit(r["Location"])[2], "/foobar") + def extract_confirm_url(self, confirm_email): + # dig out confirm_email link + msg = confirm_email.get_payload(decode=True) + line_start = "http" + confirm_url = None + for line in msg.split("\n"): + if line.strip().startswith(line_start): + confirm_url = line.strip() + self.assertTrue(confirm_url) + + return confirm_url + + def username_in_htpasswd_file(self, username): + with open(settings.HTPASSWD_FILE) as f: + for l in f: + if l.startswith(username + ":" + settings.HTDIGEST_REALM): + return True + return False + + def test_create_account(self): + make_test_data() + + url = urlreverse('create_account') + + # get + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + + # register email + email = 'new-account@example.com' + empty_outbox() + r = self.client.post(url, { 'email': email }) + self.assertEqual(r.status_code, 200) + self.assertEqual(len(outbox), 1) + + # go to confirm page + confirm_url = self.extract_confirm_url(outbox[-1]) + r = self.client.get(confirm_url) + self.assertEqual(r.status_code, 200) + + # password mismatch + r = self.client.post(confirm_url, { 'password': 'secret', 'password_confirmation': 'nosecret' }) + self.assertEqual(r.status_code, 200) + self.assertEqual(User.objects.filter(username=email).count(), 0) + + # confirm + r = self.client.post(confirm_url, { 'password': 'secret', 'password_confirmation': 'secret' }) + self.assertEqual(r.status_code, 200) + self.assertEqual(User.objects.filter(username=email).count(), 1) + self.assertEqual(Person.objects.filter(user__username=email).count(), 1) + self.assertEqual(Email.objects.filter(person__user__username=email).count(), 1) + + self.assertTrue(self.username_in_htpasswd_file(email)) def test_profile(self): make_test_data() - url = urlreverse('ietf.ietfauth.views.profile') - login_testing_unauthorized(self, "plain", url) + username = "plain" + email_address = Email.objects.filter(person__user__username=username).first().address + + url = urlreverse('account_profile') + login_testing_unauthorized(self, username, url) + + + # get + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + self.assertEqual(len(q('.form-control-static:contains("%s")' % username)), 1) + self.assertEqual(len(q('[name="active_emails"][value="%s"][checked]' % email_address)), 1) + + base_data = { + "name": u"Test Nãme", + "ascii": u"Test Name", + "ascii_short": u"T. Name", + "address": "Test address", + "affiliation": "Test Org", + "active_emails": email_address, + } + + # edit details - faulty ASCII + faulty_ascii = base_data.copy() + faulty_ascii["ascii"] = u"Test Nãme" + r = self.client.post(url, faulty_ascii) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + self.assertTrue(len(q("form .has-error")) > 0) + + # edit details + r = self.client.post(url, base_data) + self.assertEqual(r.status_code, 200) + person = Person.objects.get(user__username=username) + self.assertEqual(person.name, u"Test Nãme") + self.assertEqual(person.ascii, u"Test Name") + self.assertEqual(Person.objects.filter(alias__name=u"Test Name", user__username=username).count(), 1) + self.assertEqual(Person.objects.filter(alias__name=u"Test Nãme", user__username=username).count(), 1) + self.assertEqual(Email.objects.filter(address=email_address, person__user__username=username, active=True).count(), 1) + + # deactivate address + without_email_address = { k: v for k, v in base_data.iteritems() if k != "active_emails" } + + r = self.client.post(url, without_email_address) + self.assertEqual(r.status_code, 200) + self.assertEqual(Email.objects.filter(address=email_address, person__user__username="plain", active=True).count(), 0) + + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + self.assertEqual(len(q('[name="%s"][checked]' % email_address)), 0) + + # add email address + empty_outbox() + new_email_address = "plain2@example.com" + with_new_email_address = base_data.copy() + with_new_email_address["new_email"] = new_email_address + r = self.client.post(url, with_new_email_address) + self.assertEqual(r.status_code, 200) + self.assertEqual(len(outbox), 1) + + # confirm new email address + confirm_url = self.extract_confirm_url(outbox[-1]) + r = self.client.get(confirm_url) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + self.assertEqual(len(q('[name="action"][value=confirm]')), 1) + + r = self.client.post(confirm_url, { "action": "confirm" }) + self.assertEqual(r.status_code, 200) + self.assertEqual(Email.objects.filter(address=new_email_address, person__user__username=username, active=1).count(), 1) + + # check that we can't re-add it - that would give a duplicate + r = self.client.get(confirm_url) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + self.assertEqual(len(q('[name="action"][value="confirm"]')), 0) + + # change role email + role = Role.objects.create( + person=Person.objects.get(user__username=username), + email=Email.objects.get(address=email_address), + name=RoleName.objects.get(slug="chair"), + group=Group.objects.get(acronym="mars"), + ) + + role_email_input_name = "role_%s-email" % role.pk + + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + self.assertEqual(len(q('[name="%s"]' % role_email_input_name)), 1) + + with_changed_role_email = base_data.copy() + with_changed_role_email["active_emails"] = new_email_address + with_changed_role_email[role_email_input_name] = new_email_address + r = self.client.post(url, with_changed_role_email) + self.assertEqual(r.status_code, 200) + updated_roles = Role.objects.filter(person=role.person, name=role.name, group=role.group) + self.assertEqual(len(updated_roles), 1) + self.assertEqual(updated_roles[0].email_id, new_email_address) + + + def test_reset_password(self): + url = urlreverse('password_reset') + + user = User.objects.create(username="someone@example.com", email="someone@example.com") + user.set_password("forgotten") + user.save() + p = Person.objects.create(name="Some One", ascii="Some One", user=user) + Email.objects.create(address=user.username, person=p) # get r = self.client.get(url) self.assertEqual(r.status_code, 200) - self.assertTrue("plain" in unicontent(r)) - # post - # ... fill in + # ask for reset, wrong username + r = self.client.post(url, { 'email': "nobody@example.com" }) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + self.assertTrue(len(q("form .has-error")) > 0) - # we're missing tests of the other views + # ask for reset + empty_outbox() + r = self.client.post(url, { 'email': user.username }) + self.assertEqual(r.status_code, 200) + self.assertEqual(len(outbox), 1) + + # go to change password page + confirm_url = self.extract_confirm_url(outbox[-1]) + r = self.client.get(confirm_url) + self.assertEqual(r.status_code, 200) + + # password mismatch + r = self.client.post(confirm_url, { 'password': 'secret', 'password_confirmation': 'nosecret' }) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + self.assertTrue(len(q("form .has-error")) > 0) + + # confirm + r = self.client.post(confirm_url, { 'password': 'secret', 'password_confirmation': 'secret' }) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + self.assertEqual(len(q("form .has-error")), 0) + self.assertTrue(self.username_in_htpasswd_file(user.username)) diff --git a/ietf/ietfauth/urls.py b/ietf/ietfauth/urls.py index d7156642f..89c9f9fb0 100644 --- a/ietf/ietfauth/urls.py +++ b/ietf/ietfauth/urls.py @@ -6,17 +6,16 @@ from django.contrib.auth.views import login, logout urlpatterns = patterns('ietf.ietfauth.views', url(r'^$', 'index', name='account_index'), # url(r'^login/$', 'ietf_login'), - url(r'^login/$', login), - url(r'^logout/$', logout), + url(r'^login/$', login, name="account_login"), + url(r'^logout/$', logout, name="account_logout"), # url(r'^loggedin/$', 'ietf_loggedin'), # url(r'^loggedout/$', 'logged_out'), - url(r'^profile/$', 'profile'), + url(r'^profile/$', 'profile', name="account_profile"), # (r'^login/(?P[a-z0-9.@]+)/(?P.+)$', 'url_login'), url(r'^testemail/$', 'test_email'), url(r'^create/$', 'create_account', name='create_account'), - url(r'^confirm/(?P[\w.@+-]+)/(?P[\d]+)/(?P[\w]+)/(?P[a-f0-9]+)/$', 'confirm_account', name='confirm_account'), - url(r'^reset/$', 'password_reset_view', name='password_reset'), - url(r'^reset/confirm/(?P[\w.@+-]+)/(?P[\d]+)/(?P[\w]+)/(?P[a-f0-9]+)/$', 'confirm_password_reset', name='confirm_password_reset'), - url(r'^add_email/confirm/(?P[\w.@+-]+)/(?P[\d]+)/(?P[\w.@+-]+)/(?P[a-f0-9]+)/$', 'confirm_new_email', name='confirm_new_email'), -# url(r'^ajax/check_username/$', 'ajax_check_username', name='ajax_check_username'), + url(r'^create/confirm/(?P[^/]+)/$', 'confirm_account', name='confirm_account'), + url(r'^reset/$', 'password_reset', name='password_reset'), + url(r'^reset/confirm/(?P[^/]+)/$', 'confirm_password_reset', name='confirm_password_reset'), + url(r'^confirmnewemail/(?P[^/]+)/$', 'confirm_new_email', name='confirm_new_email'), ) diff --git a/ietf/ietfauth/utils.py b/ietf/ietfauth/utils.py index e28587aec..0680762e2 100644 --- a/ietf/ietfauth/utils.py +++ b/ietf/ietfauth/utils.py @@ -138,4 +138,3 @@ def is_authorized_in_doc_stream(user, doc): group_req = Q() return Role.objects.filter(Q(name__in=("chair", "secr", "delegate", "auth"), person__user=user) & group_req).exists() - diff --git a/ietf/ietfauth/views.py b/ietf/ietfauth/views.py index 8b0501111..909eabbc7 100644 --- a/ietf/ietfauth/views.py +++ b/ietf/ietfauth/views.py @@ -32,27 +32,25 @@ # Copyright The IETF Trust 2007, All Rights Reserved -import datetime -import hashlib -#import json - from django.conf import settings -from django.template import RequestContext from django.http import Http404 #, HttpResponse, HttpResponseRedirect -from django.shortcuts import render_to_response +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.contrib.auth.models import User #from django.utils.http import urlquote -#from django.utils.translation import ugettext as _ -from django.core.exceptions import ValidationError +import django.core.signing +from django.contrib.sites.models import Site +from django.contrib.auth.models import User from ietf.group.models import Role -from ietf.ietfauth.forms import RegistrationForm, PasswordForm, RecoverPasswordForm, TestEmailForm, PersonForm -from ietf.person.models import Person, Email +from ietf.ietfauth.forms import RegistrationForm, PasswordForm, ResetPasswordForm, TestEmailForm +from ietf.ietfauth.forms import PersonForm, RoleEmailForm, NewEmailForm +from ietf.ietfauth.htpasswd import save_htpasswd_file +from ietf.person.models import Person, Email, Alias +from ietf.utils.mail import send_mail def index(request): - return render_to_response('registration/index.html', context_instance=RequestContext(request)) + return render(request, 'registration/index.html') # def url_login(request, user, passwd): # user = authenticate(username=user, password=passwd) @@ -81,154 +79,251 @@ def index(request): # redirect_to = settings.LOGIN_REDIRECT_URL # return HttpResponseRedirect(redirect_to) -@login_required -def profile(request): - roles = [] - person = None - try: - person = request.user.person - except Person.DoesNotExist: - return render_to_response('registration/missing_person.html', context_instance=RequestContext(request)) - - if request.method == 'POST': - form = PersonForm(request.POST, instance=person) - success = False - new_emails = None - error = None - if form.is_valid(): - try: - form.save() - success = True - new_emails = form.new_emails - except Exception as e: - error = e - - return render_to_response('registration/confirm_profile_update.html', - { 'success': success, 'new_emails': new_emails, 'error': error} , - context_instance=RequestContext(request)) - else: - roles = Role.objects.filter(person=person,group__state='active').order_by('name__name','group__name') - emails = Email.objects.filter(person=person).order_by('-active','-time') - - person_form = PersonForm(instance=person) - - return render_to_response('registration/edit_profile.html', - { 'user': request.user, 'emails': emails, 'person': person, - 'roles': roles, 'person_form': person_form } , - context_instance=RequestContext(request)) - -def confirm_new_email(request, username, date, email, hash): - valid = hashlib.md5('%s%s%s%s' % (settings.SECRET_KEY, date, email, username)).hexdigest() == hash - if not valid: - raise Http404 - request_date = datetime.date(int(date[:4]), int(date[4:6]), int(date[6:])) - if datetime.date.today() > (request_date + datetime.timedelta(days=settings.DAYS_TO_EXPIRE_REGISTRATION_LINK)): - raise Http404 - success = False - - person = None - error = None - new_email = None - - try: - # First, check whether this address exists (to give a more sensible - # error when a duplicate is created). - existing_email = Email.objects.get(address=email) - print existing_email - existing_person = existing_email.person - print existing_person - error = {'address': ["Email address '%s' is already assigned to user '%s' (%s)" % - (email, existing_person.user, existing_person.name)]} - except Exception: - try: - person = Person.objects.get(user__username=username) - new_email = Email(address=email, person=person, active=True, time=datetime.datetime.now()) - new_email.full_clean() - new_email.save() - success = True - except Person.DoesNotExist: - error = {'person': ["No such user: %s" % (username)]} - except ValidationError as e: - error = e.message_dict - - return render_to_response('registration/confirm_new_email.html', - { 'username': username, 'email': email, - 'success': success, 'error': error, - 'record': new_email}, - context_instance=RequestContext(request)) - - def create_account(request): success = False if request.method == 'POST': form = RegistrationForm(request.POST) if form.is_valid(): - form.request = request - form.save() - success = True + to_email = form.cleaned_data['email'] + + auth = django.core.signing.dumps(to_email, salt="create_account") + + domain = Site.objects.get_current().domain + subject = 'Confirm registration at %s' % domain + from_email = settings.DEFAULT_FROM_EMAIL + + send_mail(request, to_email, from_email, subject, 'registration/creation_email.txt', { + 'domain': domain, + 'auth': auth, + 'username': to_email, + 'expire': settings.DAYS_TO_EXPIRE_REGISTRATION_LINK, + }) else: form = RegistrationForm() - return render_to_response('registration/create.html', - {'form': form, - 'success': success}, - context_instance=RequestContext(request)) + return render(request, 'registration/create.html', { + 'form': form, + 'success': success, + }) + +def confirm_account(request, auth): + try: + email = django.core.signing.loads(auth, salt="create_account", max_age=settings.DAYS_TO_EXPIRE_REGISTRATION_LINK * 24 * 60 * 60) + except django.core.signing.BadSignature: + raise Http404("Invalid or expired auth") + + if User.objects.filter(username=email).exists(): + return redirect("account_profile") -def process_confirmation(request, username, date, realm, hash): - valid = hashlib.md5('%s%s%s%s' % (settings.SECRET_KEY, date, username, realm)).hexdigest() == hash - if not valid: - raise Http404 - request_date = datetime.date(int(date[:4]), int(date[4:6]), int(date[6:])) - if datetime.date.today() > (request_date + datetime.timedelta(days=settings.DAYS_TO_EXPIRE_REGISTRATION_LINK)): - raise Http404 success = False if request.method == 'POST': - form = PasswordForm(request.POST, username=username) + form = PasswordForm(request.POST) if form.is_valid(): - form.save() # Also updates the httpd password file + password = form.cleaned_data["password"] + + user = User.objects.create(username=email, email=email) + user.set_password(password) + user.save() + # password is also stored in htpasswd file + save_htpasswd_file(email, password) + + # make sure the rest of the person infrastructure is + # well-connected + email_obj = Email.objects.filter(address=email).first() + + person = None + if email_obj and email_obj.person: + person = email_obj.person + + if not person: + person = Person.objects.create(user=user, + name=email, + ascii=email) + if not email_obj: + email_obj = Email.objects.create(address=email, + person=person) + email_obj.person = person + email_obj.save() + person.user = user + person.save() + success = True else: - form = PasswordForm(username=username) - return form, username, success + form = PasswordForm() -def confirm_account(request, username, date, realm, hash): - form, username, success = process_confirmation(request, username, date, realm, hash) - return render_to_response('registration/confirm.html', - {'form': form, 'email': username, 'success': success}, - context_instance=RequestContext(request)) + return render(request, 'registration/confirm_account.html', { + 'form': form, + 'email': email, + 'success': success, + }) +@login_required +def profile(request): + roles = [] + person = None -def password_reset_view(request): + try: + person = request.user.person + except Person.DoesNotExist: + return render(request, 'registration/missing_person.html') + + roles = Role.objects.filter(person=person, group__state='active').order_by('name__name', 'group__name') + emails = Email.objects.filter(person=person).order_by('-active','-time') + new_email_forms = [] + + if request.method == 'POST': + person_form = PersonForm(request.POST, instance=person) + for r in roles: + r.email_form = RoleEmailForm(r, request.POST, prefix="role_%s" % r.pk) + + for e in request.POST.getlist("new_email", []): + new_email_forms.append(NewEmailForm({ "new_email": e })) + + forms_valid = [person_form.is_valid()] + [r.email_form.is_valid() for r in roles] + [f.is_valid() for f in new_email_forms] + + email_confirmations = [] + + if all(forms_valid): + updated_person = person_form.save() + + for f in new_email_forms: + to_email = f.cleaned_data["new_email"] + if not to_email: + continue + + email_confirmations.append(to_email) + + auth = django.core.signing.dumps([person.user.username, to_email], salt="add_email") + + domain = Site.objects.get_current().domain + subject = u'Confirm email address for %s' % person.name + from_email = settings.DEFAULT_FROM_EMAIL + + send_mail(request, to_email, from_email, subject, 'registration/add_email_email.txt', { + 'domain': domain, + 'auth': auth, + 'email': to_email, + 'person': person, + 'expire': settings.DAYS_TO_EXPIRE_REGISTRATION_LINK, + }) + + + for r in roles: + e = r.email_form.cleaned_data["email"] + if r.email_id != e.pk: + r.email = e + r.save() + + active_emails = request.POST.getlist("active_emails", []) + for email in emails: + email.active = email.pk in active_emails + email.save() + + # Make sure the alias table contains any new and/or old names. + existing_aliases = set(Alias.objects.filter(person=person).values_list("name", flat=True)) + curr_names = set(x for x in [updated_person.name, updated_person.ascii, updated_person.ascii_short] if x) + new_aliases = curr_names - existing_aliases + for name in new_aliases: + Alias.objects.create(person=updated_person, name=name) + + return render(request, 'registration/confirm_profile_update.html', { + 'email_confirmations': email_confirmations, + }) + else: + for r in roles: + r.email_form = RoleEmailForm(r, prefix="role_%s" % r.pk) + + person_form = PersonForm(instance=person) + + return render(request, 'registration/edit_profile.html', { + 'user': request.user, + 'person': person, + 'person_form': person_form, + 'roles': roles, + 'emails': emails, + 'new_email_forms': new_email_forms, + }) + +def confirm_new_email(request, auth): + try: + username, email = django.core.signing.loads(auth, salt="add_email", max_age=settings.DAYS_TO_EXPIRE_REGISTRATION_LINK * 24 * 60 * 60) + except django.core.signing.BadSignature: + raise Http404("Invalid or expired auth") + + person = get_object_or_404(Person, user__username=username) + + # do another round of validation since the situation may have + # changed since submitting the request + form = NewEmailForm({ "new_email": email }) + can_confirm = form.is_valid() and email + new_email_obj = None + if request.method == 'POST' and can_confirm and request.POST.get("action") == "confirm": + new_email_obj = Email.objects.create(address=email, person=person) + + return render(request, 'registration/confirm_new_email.html', { + 'username': username, + 'email': email, + 'can_confirm': can_confirm, + 'form': form, + 'new_email_obj': new_email_obj, + }) + +def password_reset(request): success = False if request.method == 'POST': - form = RecoverPasswordForm(request.POST) + form = ResetPasswordForm(request.POST) if form.is_valid(): - form.request = request - form.save() + to_email = form.cleaned_data['email'] + + auth = django.core.signing.dumps(to_email, salt="password_reset") + + domain = Site.objects.get_current().domain + subject = 'Confirm password reset at %s' % domain + from_email = settings.DEFAULT_FROM_EMAIL + + send_mail(request, to_email, from_email, subject, 'registration/password_reset_email.txt', { + 'domain': domain, + 'auth': auth, + 'username': to_email, + 'expire': settings.DAYS_TO_EXPIRE_REGISTRATION_LINK, + }) + success = True else: - form = RecoverPasswordForm() - return render_to_response('registration/password_reset.html', - {'form': form, - 'success': success}, - context_instance=RequestContext(request)) + form = ResetPasswordForm() + return render(request, 'registration/password_reset.html', { + 'form': form, + 'success': success, + }) -def confirm_password_reset(request, username, date, realm, hash): - form, username, success = process_confirmation(request, username, date, realm, hash) - return render_to_response('registration/change_password.html', - {'form': form, - 'success': success, - 'username': username}, - context_instance=RequestContext(request)) +def confirm_password_reset(request, auth): + try: + email = django.core.signing.loads(auth, salt="password_reset", max_age=settings.DAYS_TO_EXPIRE_REGISTRATION_LINK * 24 * 60 * 60) + except django.core.signing.BadSignature: + raise Http404("Invalid or expired auth") + + user = get_object_or_404(User, username=email) + + success = False + if request.method == 'POST': + form = PasswordForm(request.POST) + if form.is_valid(): + password = form.cleaned_data["password"] + + user.set_password(password) + user.save() + # password is also stored in htpasswd file + save_htpasswd_file(user.username, password) + else: + form = PasswordForm() + + return render(request, 'registration/change_password.html', { + 'form': form, + 'email': email, + 'success': success, + }) -# def ajax_check_username(request): -# username = request.GET.get('username', '') -# error = False -# if User.objects.filter(username=username).count(): -# error = _('This email address is already registered') -# return HttpResponse(json.dumps({'error': error}), content_type='text/plain') - def test_email(request): """Set email address to which email generated in the system will be sent.""" if settings.SERVER_MODE == "production": @@ -249,11 +344,10 @@ def test_email(request): else: form = TestEmailForm(initial=dict(email=request.COOKIES.get('testmailcc'))) - r = render_to_response('ietfauth/testemail.html', - dict(form=form, - cookie=cookie if cookie != None else request.COOKIES.get("testmailcc", "") - ), - context_instance=RequestContext(request)) + r = render(request, 'ietfauth/testemail.html', { + "form": form, + "cookie": cookie if cookie != None else request.COOKIES.get("testmailcc", "") + }) if cookie != None: r.set_cookie("testmailcc", cookie) diff --git a/ietf/person/models.py b/ietf/person/models.py index db4f0f679..d09d7ae70 100644 --- a/ietf/person/models.py +++ b/ietf/person/models.py @@ -14,12 +14,15 @@ from ietf.utils.mail import send_mail_preformatted class PersonInfo(models.Model): time = models.DateTimeField(default=datetime.datetime.now) # When this Person record entered the system - name = models.CharField(max_length=255, db_index=True) # The normal unicode form of the name. This must be - # set to the same value as the ascii-form if equal. - ascii = models.CharField(max_length=255) # The normal ascii-form of the name. - ascii_short = models.CharField(max_length=32, null=True, blank=True) # The short ascii-form of the name. Also in alias table if non-null - address = models.TextField(max_length=255, blank=True) - affiliation = models.CharField(max_length=255, blank=True) + # The normal unicode form of the name. This must be + # set to the same value as the ascii-form if equal. + name = models.CharField(max_length=255, db_index=True, help_text="Preferred form of name.") + # The normal ascii-form of the name. + ascii = models.CharField("ASCII", max_length=255, help_text="Name as rendered in ASCII (Latin, unaccented) characters.") + # The short ascii-form of the name. Also in alias table if non-null + ascii_short = models.CharField("ASCII short", max_length=32, null=True, blank=True, help_text="Example: A. Nonymous. Fill in this with initials and surname only if taking the initials and surname of the ASCII name above produces an incorrect initials-only form. (Blank is OK).") + affiliation = models.CharField(max_length=255, blank=True, help_text="Employer, university, sponsor, etc.") + address = models.TextField(max_length=255, blank=True, help_text="Postal mailing address.") def __unicode__(self): return self.plain_name() diff --git a/ietf/templates/registration/add_email_email.txt b/ietf/templates/registration/add_email_email.txt index 33a218c0c..6df1632a4 100644 --- a/ietf/templates/registration/add_email_email.txt +++ b/ietf/templates/registration/add_email_email.txt @@ -1,12 +1,9 @@ {% autoescape off %} Hello, -We have received a request to add the email address '{{ email }}' -to the user account '{{ user }}' at '{{ domain }}'. -If you requested this change, please confirm that this is your email -address by clicking on following link: +{% filter wordwrap:73 %}We have received a request to add the email address {{ email }} to the user account '{{ person.user }}' at {{ domain }}. If you requested this change, please confirm that this is your email address by clicking on following link:{% endfilter %} - https://{{ domain }}{% url "confirm_new_email" user today email auth %} + https://{{ domain }}{% url "confirm_new_email" auth %} This link will expire in {{ expire }} days. diff --git a/ietf/templates/registration/change_password.html b/ietf/templates/registration/change_password.html index 26321558a..ca4debd71 100644 --- a/ietf/templates/registration/change_password.html +++ b/ietf/templates/registration/change_password.html @@ -18,7 +18,7 @@ {% else %}

Change password

-

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

+

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

{% csrf_token %} {% bootstrap_form form %} diff --git a/ietf/templates/registration/confirm.html b/ietf/templates/registration/confirm_account.html similarity index 72% rename from ietf/templates/registration/confirm.html rename to ietf/templates/registration/confirm_account.html index 16ad9b94c..8abd56ebe 100644 --- a/ietf/templates/registration/confirm.html +++ b/ietf/templates/registration/confirm_account.html @@ -12,13 +12,13 @@ {% if success %}

Account creation successful

-

Your account with login name {{ email }} has been created, using the password you have selected.

- Sign in +

Your account with login {{ email }} has been created, using the password you have selected.

+ Sign in {% else %}

Complete account creation

-

In order to complete the setup of your account with login name {{ email }}, please choose a password:

+

In order to complete the setup of your account with login {{ email }}, please choose a password:

{% csrf_token %} {% bootstrap_form form %} diff --git a/ietf/templates/registration/confirm_new_email.html b/ietf/templates/registration/confirm_new_email.html index 2eadeec2f..0568c2960 100644 --- a/ietf/templates/registration/confirm_new_email.html +++ b/ietf/templates/registration/confirm_new_email.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {# Copyright The IETF Trust 2015, All Rights Reserved #} -{% load origin %} +{% load origin bootstrap3 %} {% block title %}Confirm new email address{% endblock %} @@ -8,17 +8,28 @@ {% origin %}

Confirm new email address

- {% if success %} -

Your account with login name {{ username }} has been updated to include the email address {{ email }}.

- Edit profile + {% if not can_confirm %} +

An error has occured when attempting to add the email address {{ email }} to your account {{ username }}.

+ + {% bootstrap_form_errors form %} + +

+ Edit profile +

+ {% elif new_email_obj %} +

Your account {{ username }} has been updated to include the email address {{ email }}.

+ +

+ Edit profile +

{% else %} -

An error has occured when attempting to add the email address '{{ email }}' to your account '{{ username }}'.

-

    - {% for field,msgs in error.items %} - {% for msg in msgs %} -
  • {{field}}: {{msg}} - {% endfor %} - {% endfor %} -
+

Confirm that you want to add the email address {{ email }} to your account {{ username }}.

+ + + {% csrf_token %} + {% buttons %} + + {% endbuttons %} +
{% endif %} {% endblock %} diff --git a/ietf/templates/registration/confirm_profile_update.html b/ietf/templates/registration/confirm_profile_update.html index e2066c59d..c0d385c01 100644 --- a/ietf/templates/registration/confirm_profile_update.html +++ b/ietf/templates/registration/confirm_profile_update.html @@ -2,23 +2,18 @@ {# Copyright The IETF Trust 2015, All Rights Reserved #} {% load origin %} -{% block title %}Profile update{% endblock %} +{% block title %}Profile update successful{% endblock %} {% block content %} {% origin %} - {% if success %} -

Profile update successful

-

Your account has been successfully updated to reflect the changes you submitted.

- {% for email in new_emails %} -

A confirmation email has been sent to {{email}}. The email will be activated after you click on the link it contains.

- {% endfor %} - Edit profile - {% else %} -

Profile update unsuccessful

-

An error has occurred when attempting to update your account.

- {% if error %} -

{{ error }}

- {% endif %} - {% endif %} +

Profile update successful

+ +

Your account has been updated to reflect the changes you submitted.

+ + {% for email in email_confirmations %} +

A confirmation email has been sent to {{email}}. The email will be activated after you click on the link it contains.

+ {% endfor %} + + Edit profile {% endblock %} diff --git a/ietf/templates/registration/creation_email.txt b/ietf/templates/registration/creation_email.txt index 63496825f..1edd103ac 100644 --- a/ietf/templates/registration/creation_email.txt +++ b/ietf/templates/registration/creation_email.txt @@ -1,12 +1,9 @@ {% autoescape off %} Hello, -We have received an account creation request for {{ username }} -at {{ domain }}. In order to set a new password for the -{{ username }} account, please go to the following link and -follow the instructions there: +{% filter wordwrap:73 %}We have received an account creation request for {{ username }} at {{ domain }}. In order to set a new password for the {{ username }} account, please go to the following link and follow the instructions there:{% endfilter %} - https://{{ domain }}{% url "confirm_account" username today realm auth %} + https://{{ domain }}{% url "confirm_account" auth %} This link will expire in {{ expire }} days. @@ -14,4 +11,4 @@ Best regards, The datatracker login manager service (for the IETF Secretariat) -{% endautoescape %} \ No newline at end of file +{% endautoescape %} diff --git a/ietf/templates/registration/edit_profile.html b/ietf/templates/registration/edit_profile.html index 146d0e69f..9c4bccad5 100644 --- a/ietf/templates/registration/edit_profile.html +++ b/ietf/templates/registration/edit_profile.html @@ -2,7 +2,7 @@ {# Copyright The IETF Trust 2015, All Rights Reserved #} {% load origin %} -{% load widget_tweaks %} +{% load widget_tweaks bootstrap3 %} {% block title %}Profile for {{ user }}{% endblock %} @@ -13,6 +13,8 @@
{% csrf_token %} + {% bootstrap_form_errors person_form 'non_fields' %} +
@@ -20,32 +22,6 @@
-
- -
- {% for role in roles %} -
-
- -
-
-
Email to use for {{ role.name|lower }} role in {{ role.group.acronym|upper }} ({{ role.group.type }}).
-
-
- {% endfor %} -
-
-
@@ -54,8 +30,8 @@ {% for email in emails %}
{% endfor %} @@ -67,85 +43,23 @@
+ {% for f in new_email_forms %} + {% bootstrap_field f.new_email layout="horizontal" show_label=False %} + {% endfor %} +
- +
+ +
-
- -
-
-
- {{person_form.name|add_class:"form-control"}} -
-
-
The preferred form of your name.
-
-
-
-
+ {% for role in roles %} + {% bootstrap_field role.email_form.email layout="horizontal" show_label=False %} + {% endfor %} -
- -
-
-
- {{person_form.ascii|add_class:"form-control"}} -
-
-
Your name as rendered in ASCII (Latin, unaccented) characters.
-
-
-
-
- -
- -
-
-
- {{person_form.ascii_short|add_class:"form-control"}} -
-
- - Example: A. Nonymous. Fill in this with initials and surname only if - taking the initials and surname of your ASCII name above produces an incorrect - initials-only form. (Blank is ok). - -
-
-
-
- -
- -
-
-
- {{person_form.affiliation|add_class:"form-control"}} -
-
-
Employer, university, sponsor, etc.
-
-
-
-
- -
- -
-
-
- {{person_form.address|add_class:"form-control"}} -
-
-
Postal mailing address.
-
-
-
-
+ {% bootstrap_form person_form layout="horizontal" %}
@@ -158,23 +72,24 @@ {% block js %} {% endblock %} diff --git a/ietf/templates/registration/password_reset.html b/ietf/templates/registration/password_reset.html index 76f531416..f65127384 100644 --- a/ietf/templates/registration/password_reset.html +++ b/ietf/templates/registration/password_reset.html @@ -27,6 +27,5 @@ {% endbuttons %} - {% endif %} {% endblock %} diff --git a/ietf/templates/registration/password_reset_email.txt b/ietf/templates/registration/password_reset_email.txt index baea1b952..1ccea847e 100644 --- a/ietf/templates/registration/password_reset_email.txt +++ b/ietf/templates/registration/password_reset_email.txt @@ -1,12 +1,9 @@ {% autoescape off %} Hello, -We have received a password reset request for {{ username }} -at {{ domain }}. In order to set a new password for the -{{ username }} account, please go to the following link and -follow the instructions there: +{% filter wordwrap:73 %}We have received a password reset request for {{ username }} at {{ domain }}. In order to set a new password for the {{ username }} account, please go to the following link and follow the instructions there:{% endfilter %} - https://{{ domain }}{% url "confirm_password_reset" username today realm auth %} + https://{{ domain }}{% url "confirm_password_reset" auth %} This link will expire in {{ expire }} days. @@ -17,4 +14,4 @@ Best regards, The datatracker login manager service (for the IETF Secretariat) -{% endautoescape %} \ No newline at end of file +{% endautoescape %}