diff --git a/docs/authentication.rst b/docs/authentication.rst index c16a9280..ddcee87c 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -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 diff --git a/pgweb/account/forms.py b/pgweb/account/forms.py index a9f322ee..609101e2 100644 --- a/pgweb/account/forms.py +++ b/pgweb/account/forms.py @@ -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 index 00000000..0d34dc9e --- /dev/null +++ b/pgweb/account/migrations/0005_secondaryemail.py @@ -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', + ), + ] diff --git a/pgweb/account/models.py b/pgweb/account/models.py index b069f87d..447d49d0 100644 --- a/pgweb/account/models.py +++ b/pgweb/account/models.py @@ -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', ) diff --git a/pgweb/account/urls.py b/pgweb/account/urls.py index 6cd1463a..74d348b6 100644 --- a/pgweb/account/urls.py +++ b/pgweb/account/urls.py @@ -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), diff --git a/pgweb/account/views.py b/pgweb/account/views.py index 3a11428d..655eddc9 100644 --- a/pgweb/account/views.py +++ b/pgweb/account/views.py @@ -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)) diff --git a/pgweb/core/management/commands/cleanup_old_records.py b/pgweb/core/management/commands/cleanup_old_records.py index f12799f5..70368a34 100644 --- a/pgweb/core/management/commands/cleanup_old_records.py +++ b/pgweb/core/management/commands/cleanup_old_records.py @@ -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 index 00000000..821afdb7 --- /dev/null +++ b/templates/account/email_add_email.txt @@ -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 index 8622c6ab..00000000 --- a/templates/account/email_change_email.txt +++ /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 index c222cd3c..00000000 --- a/templates/account/emailchangecompleted.html +++ /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%} -

Email changed

-

-Your email has successfully been changed to {{user.email}}. -

-

-Please note that if you are using your account from a different -community site than www.postgresql.org, you may need to log -out and back in again for the email to be updated on that site. -

-{%else%} -

Change email

-

-The token {{token}} was not found. -

-

-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. -

-

-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. -

-{%endif%} - -

-Return to your profile. -

-{%endblock%} diff --git a/templates/account/emailchangeform.html b/templates/account/emailchangeform.html deleted file mode 100644 index e21e9919..00000000 --- a/templates/account/emailchangeform.html +++ /dev/null @@ -1,60 +0,0 @@ -{%extends "base/page.html"%} -{% load pgfilters %} -{%block title%}Change email{%endblock%} -{%block contents%} -

Change email

-{%if token%} -

Awaiting confirmation

-

-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. -

-

-The token will be valid for approximately 24 hours, after which it will -be automatically deleted. -

-

-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. -

- -

Change email

-{%else%} -

-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. -

-{%endif%} - -
{% csrf_token %} - {% if form.errors %} - - {% endif %} - {% for field in form %} -
- {% if field.errors %} - {% for e in field.errors %} -
{{e}}
- {% endfor %} - {% endif %} - -
- {{ field|field_class:"form-control" }} -
-
- {% endfor %} -
- -
-
-{%endblock%} diff --git a/templates/account/userprofileform.html b/templates/account/userprofileform.html index cd56fa7a..f07b6b60 100644 --- a/templates/account/userprofileform.html +++ b/templates/account/userprofileform.html @@ -17,19 +17,6 @@ {{ user.username }} -
- -
- {{ user.email }} - {% if can_change_email %} - (change) - {% else %} -

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).

- {% endif %} -
-
{% for field in userform %}
{% if field.errors %} @@ -66,6 +53,47 @@
{% endfor %} + +

Secondary email addresses

+

You can add one or more secondary email addresses to your account, which can be used for example to subscribe to mailing lists.

+ {%if secondaryaddresses%} +

Note that deleting any address here will cascade to connected system and can for example lead to being unsubscribed from mailing lists automatically.

+

+

The following secondary addresses are currently registered with your account:

+ + {%if secondarypending %} +

+ 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. +

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

Add new email address

+ {%for field in secondaryemailform%} +
+ {% if field.errors %} + {% for e in field.errors %} +
{{e}}
+ {% endfor %} + {% endif %} + +
+ {{ field|field_class:"form-control" }} +
+
+ {%endfor%} + {% if contribform %}

Edit contributor information

You can edit the information that's shown on the contributors page. Please be careful as your changes will take effect immediately! @@ -89,6 +117,7 @@ {% endfor %} {% endif %} +