Teach pgweb to handle secondary email addresses
authorMagnus Hagander <magnus@hagander.net>
Fri, 7 Aug 2020 11:32:10 +0000 (13:32 +0200)
committerMagnus Hagander <magnus@hagander.net>
Tue, 11 Aug 2020 09:33:46 +0000 (11:33 +0200)
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.

12 files changed:
docs/authentication.rst
pgweb/account/forms.py
pgweb/account/migrations/0005_secondaryemail.py [new file with mode: 0644]
pgweb/account/models.py
pgweb/account/urls.py
pgweb/account/views.py
pgweb/core/management/commands/cleanup_old_records.py
templates/account/email_add_email.txt [new file with mode: 0644]
templates/account/email_change_email.txt [deleted file]
templates/account/emailchangecompleted.html [deleted file]
templates/account/emailchangeform.html [deleted file]
templates/account/userprofileform.html

index c16a92808f3eb89208a1540657a4228194d9c501..ddcee87c409cb81dd5fd3af73b2ec7a4cb29b01d 100644 (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
index a9f322ee750b2c10464194f8120a07fffab9b51e..609101e23f69ef0935b9a7d790db6b396007f1b4 100644 (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:
diff --git a/pgweb/account/migrations/0005_secondaryemail.py b/pgweb/account/migrations/0005_secondaryemail.py
new file mode 100644 (file)
index 0000000..0d34dc9
--- /dev/null
@@ -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',
+        ),
+    ]
index b069f87dd190a6d7980ea630f73a9d2585f0e361..447d49d0ab68e2740fcda8a118c545bd3473e9f4 100644 (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', )
index 6cd1463a3f18d02433f8e4a7610283069f1bbf76..74d348b6b479de4a6fdf896610bb94c7ec8dd43b 100644 (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),
index 3a11428df13cab40e3ae4edd67610c19cdc4306d..655eddc94b6655b88f8ced8b27bb0569a21045e3 100644 (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
-
-    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()
+def confirm_add_email(request, tokenhash):
+    addr = get_object_or_404(SecondaryEmail, user=request.user, token=tokenhash)
 
-            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))
 
index f12799f5578d87bbb48cf8390551496436bf1ff2..70368a34d2f64975cb7a6c76bfd2693699da38c4 100644 (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()
diff --git a/templates/account/email_add_email.txt b/templates/account/email_add_email.txt
new file mode 100644 (file)
index 0000000..821afdb
--- /dev/null
@@ -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.
diff --git a/templates/account/email_change_email.txt b/templates/account/email_change_email.txt
deleted file mode 100644 (file)
index 8622c6a..0000000
+++ /dev/null
@@ -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}}/
-
diff --git a/templates/account/emailchangecompleted.html b/templates/account/emailchangecompleted.html
deleted file mode 100644 (file)
index c222cd3..0000000
+++ /dev/null
@@ -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%}
diff --git a/templates/account/emailchangeform.html b/templates/account/emailchangeform.html
deleted file mode 100644 (file)
index e21e991..0000000
+++ /dev/null
@@ -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%}
index cd56fa7ad8093fc12578ea0ca50464838b45a292..f07b6b605ac99cf0dd6ae07c178eb533ef4b6e43 100644 (file)
       {{ 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 %}
       </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!
       </div>
     {% endfor %}
   {% endif %}
+
   <div class="submit-row">
     <input class="btn btn-primary" type="submit" value="Save" />
   </div>