Teach pgweb to handle secondary email addresses

This allows each account to have more than one email address, of which
one is primary. Adding more addresses will trigger an email with a
verification link (of course). The field previously known as "email" is
now changed to be "primary email".

Change the profile form to allow freely changing between the added
addresses which one is the primary. Remove the functionality to directly
change the primary email -- instead one has to add a new address first
and then change to that one, which simplifies several things in the
handling.
This commit is contained in:
Magnus Hagander
2020-08-07 13:32:10 +02:00
parent b97aa1d581
commit fb99733afe
12 changed files with 195 additions and 204 deletions

View File

@ -48,7 +48,9 @@ The flow of an authentication in the 2.0 system is fairly simple:
l
The last name of the user logged in
e
The email address of the user logged in
The primary email address of the user logged in
se
A comma separated list of secondary email addresses for the user logged in
d
base64 encoded data block to be passed along in confirmation (optional)
su
@ -148,8 +150,10 @@ The flow for search is:
u
Username
e
Email
Primary email
f
First name
l
Last name
se
Array of secondary email addresses

View File

@ -6,6 +6,7 @@ import re
from django.contrib.auth.models import User
from pgweb.core.models import UserProfile
from pgweb.contributors.models import Contributor
from .models import SecondaryEmail
from .recaptcha import ReCaptchaField
@ -121,14 +122,25 @@ class UserProfileForm(forms.ModelForm):
class UserForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
primaryemail = forms.ChoiceField(choices=[], required=True, label='Primary email address')
def __init__(self, can_change_email, secondaryaddresses, *args, **kwargs):
super(UserForm, self).__init__(*args, **kwargs)
self.fields['first_name'].required = True
self.fields['last_name'].required = True
if can_change_email:
self.fields['primaryemail'].choices = [(self.instance.email, self.instance.email), ] + [(a.email, a.email) for a in secondaryaddresses if a.confirmed]
if not secondaryaddresses:
self.fields['primaryemail'].help_text = "To change the primary email address, first add it as a secondary address below"
else:
self.fields['primaryemail'].choices = [(self.instance.email, self.instance.email), ]
self.fields['primaryemail'].help_text = "You cannot change the primary email of this account since it is connected to an external authentication system"
self.fields['primaryemail'].widget.attrs['disabled'] = True
self.fields['primaryemail'].required = False
class Meta:
model = User
fields = ('first_name', 'last_name', )
fields = ('primaryemail', 'first_name', 'last_name', )
class ContributorForm(forms.ModelForm):
@ -137,16 +149,16 @@ class ContributorForm(forms.ModelForm):
exclude = ('ctype', 'lastname', 'firstname', 'user', )
class ChangeEmailForm(forms.Form):
email = forms.EmailField()
email2 = forms.EmailField(label="Repeat email")
class AddEmailForm(forms.Form):
email1 = forms.EmailField(label="New email", required=False)
email2 = forms.EmailField(label="Repeat email", required=False)
def __init__(self, user, *args, **kwargs):
super(ChangeEmailForm, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self.user = user
def clean_email(self):
email = self.cleaned_data['email'].lower()
def clean_email1(self):
email = self.cleaned_data['email1'].lower()
if email == self.user.email:
raise forms.ValidationError("This is your existing email address!")
@ -154,14 +166,23 @@ class ChangeEmailForm(forms.Form):
if User.objects.filter(email=email).exists():
raise forms.ValidationError("A user with this email address is already registered")
try:
s = SecondaryEmail.objects.get(email=email)
if s.user == self.user:
raise forms.ValidationError("This email address is already connected to your account")
else:
raise forms.ValidationError("A user with this email address is already registered")
except SecondaryEmail.DoesNotExist:
pass
return email
def clean_email2(self):
# If the primary email checker had an exception, the data will be gone
# from the cleaned_data structure
if 'email' not in self.cleaned_data:
if 'email1' not in self.cleaned_data:
return self.cleaned_data['email2'].lower()
email1 = self.cleaned_data['email'].lower()
email1 = self.cleaned_data['email1'].lower()
email2 = self.cleaned_data['email2'].lower()
if email1 != email2:

View File

@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.27 on 2020-08-06 16:12
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('account', '0004_cauth_last_login'),
]
operations = [
migrations.CreateModel(
name='SecondaryEmail',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email', models.EmailField(max_length=75, unique=True)),
('confirmed', models.BooleanField(default=False)),
('token', models.CharField(max_length=100)),
('sentat', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ('email', ),
},
),
migrations.DeleteModel(
name='EmailChangeToken',
),
]

View File

@ -35,8 +35,12 @@ class CommunityAuthConsent(models.Model):
unique_together = (('user', 'org'), )
class EmailChangeToken(models.Model):
user = models.OneToOneField(User, null=False, blank=False, on_delete=models.CASCADE)
email = models.EmailField(max_length=75, null=False, blank=False)
class SecondaryEmail(models.Model):
user = models.ForeignKey(User, null=False, blank=False, on_delete=models.CASCADE)
email = models.EmailField(max_length=75, null=False, blank=False, unique=True)
confirmed = models.BooleanField(null=False, blank=False, default=False)
token = models.CharField(max_length=100, null=False, blank=False)
sentat = models.DateTimeField(null=False, blank=False, auto_now=True)
class Meta:
ordering = ('email', )

View File

@ -18,8 +18,7 @@ urlpatterns = [
# Profile
url(r'^profile/$', pgweb.account.views.profile),
url(r'^profile/change_email/$', pgweb.account.views.change_email),
url(r'^profile/change_email/([0-9a-f]+)/$', pgweb.account.views.confirm_change_email),
url(r'^profile/add_email/([0-9a-f]+)/$', pgweb.account.views.confirm_add_email),
# List of items to edit
url(r'^edit/(.*)/$', pgweb.account.views.listobjects),

View File

@ -10,7 +10,7 @@ from django.contrib.auth.tokens import default_token_generator
from django.contrib.auth import logout as django_logout
from django.conf import settings
from django.db import transaction, connection
from django.db.models import Q
from django.db.models import Q, Prefetch
import base64
import urllib.parse
@ -32,12 +32,12 @@ from pgweb.contributors.models import Contributor
from pgweb.downloads.models import Product
from pgweb.profserv.models import ProfessionalService
from .models import CommunityAuthSite, CommunityAuthConsent, EmailChangeToken
from .models import CommunityAuthSite, CommunityAuthConsent, SecondaryEmail
from .forms import PgwebAuthenticationForm
from .forms import CommunityAuthConsentForm
from .forms import SignupForm, SignupOauthForm
from .forms import UserForm, UserProfileForm, ContributorForm
from .forms import ChangeEmailForm, PgwebPasswordResetForm
from .forms import AddEmailForm, PgwebPasswordResetForm
import logging
log = logging.getLogger(__name__)
@ -110,96 +110,81 @@ def profile(request):
contribform = None
secondaryaddresses = SecondaryEmail.objects.filter(user=request.user)
if request.method == 'POST':
# Process this form
userform = UserForm(data=request.POST, instance=request.user)
userform = UserForm(can_change_email, secondaryaddresses, data=request.POST, instance=request.user)
profileform = UserProfileForm(data=request.POST, instance=profile)
secondaryemailform = AddEmailForm(request.user, data=request.POST)
if contrib:
contribform = ContributorForm(data=request.POST, instance=contrib)
if userform.is_valid() and profileform.is_valid() and (not contrib or contribform.is_valid()):
userform.save()
if userform.is_valid() and profileform.is_valid() and secondaryemailform.is_valid() and (not contrib or contribform.is_valid()):
user = userform.save()
# Email takes some magic special handling, since we only allow picking of existing secondary emails, but it's
# not a foreign key (due to how the django auth model works).
if can_change_email and userform.cleaned_data['primaryemail'] != user.email:
# Changed it!
oldemail = user.email
# Create a secondary email for the old primary one
SecondaryEmail(user=user, email=oldemail, confirmed=True, token='').save()
# Flip the main email
user.email = userform.cleaned_data['primaryemail']
user.save(update_fields=['email', ])
# Finally remove the old secondary address, since it can`'t be both primary and secondary at the same time
SecondaryEmail.objects.filter(user=user, email=user.email).delete()
log.info("User {} changed primary email from {} to {}".format(user.username, oldemail, user.email))
profileform.save()
if contrib:
contribform.save()
return HttpResponseRedirect("/account/")
if secondaryemailform.cleaned_data.get('email1', ''):
sa = SecondaryEmail(user=request.user, email=secondaryemailform.cleaned_data['email1'], token=generate_random_token())
sa.save()
send_template_mail(
settings.ACCOUNTS_NOREPLY_FROM,
sa.email,
'Your postgresql.org community account',
'account/email_add_email.txt',
{'secondaryemail': sa, 'user': request.user, }
)
for k, v in request.POST.items():
if k.startswith('deladdr_') and v == '1':
ii = int(k[len('deladdr_'):])
SecondaryEmail.objects.filter(user=request.user, id=ii).delete()
return HttpResponseRedirect(".")
else:
# Generate form
userform = UserForm(instance=request.user)
userform = UserForm(can_change_email, secondaryaddresses, instance=request.user)
profileform = UserProfileForm(instance=profile)
secondaryemailform = AddEmailForm(request.user)
if contrib:
contribform = ContributorForm(instance=contrib)
return render_pgweb(request, 'account', 'account/userprofileform.html', {
'userform': userform,
'profileform': profileform,
'secondaryemailform': secondaryemailform,
'secondaryaddresses': secondaryaddresses,
'secondarypending': any(not a.confirmed for a in secondaryaddresses),
'contribform': contribform,
'can_change_email': can_change_email,
})
@login_required
@transaction.atomic
def change_email(request):
tokens = EmailChangeToken.objects.filter(user=request.user)
token = len(tokens) and tokens[0] or None
def confirm_add_email(request, tokenhash):
addr = get_object_or_404(SecondaryEmail, user=request.user, token=tokenhash)
if request.user.password == OAUTH_PASSWORD_STORE:
# Link shouldn't exist in this case, so just throw an unfriendly
# error message.
return HttpSimpleResponse(request, "Account error", "This account cannot change email address as it's connected to a third party login site.")
if request.method == 'POST':
form = ChangeEmailForm(request.user, data=request.POST)
if form.is_valid():
# If there is an existing token, delete it
if token:
token.delete()
# Create a new token
token = EmailChangeToken(user=request.user,
email=form.cleaned_data['email'].lower(),
token=generate_random_token())
token.save()
send_template_mail(
settings.ACCOUNTS_NOREPLY_FROM,
form.cleaned_data['email'],
'Your postgresql.org community account',
'account/email_change_email.txt',
{'token': token, 'user': request.user, }
)
return HttpResponseRedirect('done/')
else:
form = ChangeEmailForm(request.user)
return render_pgweb(request, 'account', 'account/emailchangeform.html', {
'form': form,
'token': token,
})
@login_required
@transaction.atomic
def confirm_change_email(request, tokenhash):
tokens = EmailChangeToken.objects.filter(user=request.user, token=tokenhash)
token = len(tokens) and tokens[0] or None
if request.user.password == OAUTH_PASSWORD_STORE:
# Link shouldn't exist in this case, so just throw an unfriendly
# error message.
return HttpSimpleResponse(request, "Account error", "This account cannot change email address as it's connected to a third party login site.")
if token:
# Valid token find, so change the email address
request.user.email = token.email.lower()
request.user.save()
token.delete()
return render_pgweb(request, 'account', 'account/emailchangecompleted.html', {
'token': tokenhash,
'success': token and True or False,
})
# Valid token found, so mark the address as confirmed.
addr.confirmed = True
addr.token = ''
addr.save()
return HttpResponseRedirect('/account/profile/')
@login_required
@ -538,6 +523,7 @@ def communityauth(request, siteid):
'f': request.user.first_name.encode('utf-8'),
'l': request.user.last_name.encode('utf-8'),
'e': request.user.email.encode('utf-8'),
'se': ','.join([a.email for a in SecondaryEmail.objects.filter(user=request.user, confirmed=True).order_by('email')]).encode('utf8'),
}
if d:
info['d'] = d.encode('utf-8')
@ -626,9 +612,15 @@ def communityauth_search(request, siteid):
else:
raise Http404('No search term specified')
users = User.objects.filter(q)
users = User.objects.prefetch_related(Prefetch('secondaryemail_set', queryset=SecondaryEmail.objects.filter(confirmed=True))).filter(q)
j = json.dumps([{'u': u.username, 'e': u.email, 'f': u.first_name, 'l': u.last_name} for u in users])
j = json.dumps([{
'u': u.username,
'e': u.email,
'f': u.first_name,
'l': u.last_name,
'se': [a.email for a in u.secondaryemail_set.all()],
} for u in users])
return HttpResponse(_encrypt_site_response(site, j))

View File

@ -15,7 +15,7 @@ from django.db import connection, transaction
from datetime import datetime, timedelta
from pgweb.account.models import EmailChangeToken
from pgweb.account.models import SecondaryEmail
class Command(BaseCommand):
@ -33,4 +33,4 @@ class Command(BaseCommand):
# Clean up old email change tokens
with transaction.atomic():
EmailChangeToken.objects.filter(sentat__lt=datetime.now() - timedelta(hours=24)).delete()
SecondaryEmail.objects.filter(confirmed=False, sentat__lt=datetime.now() - timedelta(hours=24)).delete()

View File

@ -0,0 +1,9 @@
Somebody, probably you, attempted to add this email address to
the PostgreSQL community account {{user.username}}.
To confirm the addition of this email address, please click
the following link:
{{link_root}}/account/profile/add_email/{{secondaryemail.token}}/
If you do not approve of this, you can ignore this email.

View File

@ -1,8 +0,0 @@
Somebody, probably you, attempted to change the email of the
PostgreSQL community account {{user.username}} to this email address.
To confirm this change of email address, please click the following
link:
{{link_root}}/account/profile/change_email/{{token.token}}/

View File

@ -1,34 +0,0 @@
{%extends "base/page.html"%}
{%block title%}{%if success%}Email changed{%else%}Change email{%endif%}{%endblock%}
{%block contents%}
{%if success%}
<h1>Email changed <i class="fas fa-envelope"></i></h1>
<p>
Your email has successfully been changed to {{user.email}}.
</p>
<p>
Please note that if you are using your account from a different
community site than <em>www.postgresql.org</em>, you may need to log
out and back in again for the email to be updated on that site.
</p>
{%else%}
<h1>Change email <i class="fas fa-envelope"></i></h1>
<p>
The token <code>{{token}}</code> was not found.
</p>
<p>
This can be because it expired (tokens are valid for approximately
24 hours), or because you did not paste the complete URL without any
spaces.
</p>
<p>
Double check the URL, and if it is correct, restart the process by
clicking "change" in your profile to generate a new token and try again.
</p>
{%endif%}
<p>
<a href="/account/profile/">Return</a> to your profile.
</p>
{%endblock%}

View File

@ -1,60 +0,0 @@
{%extends "base/page.html"%}
{% load pgfilters %}
{%block title%}Change email{%endblock%}
{%block contents%}
<h1>Change email <i class="fas fa-envelope"></i></h1>
{%if token%}
<h2>Awaiting confirmation</h2>
<p>
A confirmation token was sent to {{token.email}} on {{token.sentat|date:"Y-m-d H:i"}}.
Wait for this token to arrive, and then click the link that is sent
in the email to confirm the change of email.
</p>
<p>
The token will be valid for approximately 24 hours, after which it will
be automatically deleted.
</p>
<p>
To create a new token (and a new email), fill out the form below again.
Note that once a new token is created, the old token will no longer be
valid for use.
</p>
<h2>Change email</h2>
{%else%}
<p>
To change your email address, input the new address below. Once you
click "Change email", a verification token will be sent to the new email address,
and once you click the link in that email, your email will be changed.
</p>
{%endif%}
<form method="post" action=".">{% csrf_token %}
{% if form.errors %}
<div class="alert alert-danger" role="alert">
Please correct the errors below, and re-submit the form.
</div>
{% endif %}
{% for field in form %}
<div class="form-group row">
{% if field.errors %}
{% for e in field.errors %}
<div class="col-lg-12 alert alert-danger">{{e}}</div>
{% endfor %}
{% endif %}
<label class="col-form-label col-sm-3" for="{{ field.id }}">
{{ field.label|title }}
{% if field.help_text %}
<p><small>{{ field.help_text }}</small></p>
{% endif %}
</label>
<div class="col-sm-9">
{{ field|field_class:"form-control" }}
</div>
</div>
{% endfor %}
<div class="submit-row">
<input class="btn btn-primary" type="submit" value="Change Email" />
</div>
</form>
{%endblock%}

View File

@ -17,19 +17,6 @@
{{ user.username }}
</div>
</div>
<div class="form-group row">
<label class="col-form-label col-sm-3">Email:</label>
<div class="col-sm-9">
{{ user.email }}
{% if can_change_email %}
(<em><a href="change_email/">change</a></em>)
{% else %}
<p><em>The email address of this account cannot be changed, because the account does
not have a local password, most likely because it's connected to a third
party system (such as Google or Facebook).</em></p>
{% endif %}
</div>
</div>
{% for field in userform %}
<div class="form-group row">
{% if field.errors %}
@ -66,6 +53,47 @@
</div>
</div>
{% endfor %}
<h2>Secondary email addresses</h2>
<p>You can add one or more secondary email addresses to your account, which can be used for example to subscribe to mailing lists.</p>
{%if secondaryaddresses%}
<p>Note that deleting any address here will cascade to connected system and can for example lead to being unsubscribed from mailing lists automatically.</p>
<p></p>
<p>The following secondary addresses are currently registered with your account:</p>
<ul>
{% for a in secondaryaddresses %}
<li>{{a.email}}{%if not a.confirmed%} <em>(awaiting confirmation since {{a.sentat}})</em>{%endif%} (<input type="checkbox" name="deladdr_{{a.id}}" value="1"> Delete)</li>
{%endfor%}
</ul>
{%if secondarypending %}
<p>
One or more of the secondary addresses on your account are listed as pending. An email has been sent to the address to confirm that
you are in control of the address. Open the link in this email (while logged in to this account) to confirm the account. If an email
address is not confirmed within approximately 24 hours, it will be deleted. If you have not received the confirmation token, you
can delete the address and re-add it, to have the system re-send the verification email.
</p>
{%endif%}
{%endif%}
<h3>Add new email address</h3>
{%for field in secondaryemailform%}
<div class="form-group row">
{% if field.errors %}
{% for e in field.errors %}
<div class="col-lg-12 alert alert-danger">{{e}}</div>
{% endfor %}
{% endif %}
<label class="col-form-label col-sm-3" for="{{ field.id }}">
{{ field.label }}
{% if field.help_text %}
<p><small>{{ field.help_text }}</small></p>
{% endif %}
</label>
<div class="col-sm-9">
{{ field|field_class:"form-control" }}
</div>
</div>
{%endfor%}
{% if contribform %}
<h2>Edit contributor information</h2>
<p>You can edit the information that's shown on the <a href="/community/contributors/" target="_blank">contributors</a> page. Please be careful as your changes will take effect immediately!
@ -89,6 +117,7 @@
</div>
{% endfor %}
{% endif %}
<div class="submit-row">
<input class="btn btn-primary" type="submit" value="Save" />
</div>