From 6ba623aac129f197c6a4f528d03e5f14bfa80934 Mon Sep 17 00:00:00 2001 From: Peter Thomassen Date: Thu, 11 Aug 2022 10:11:19 -0400 Subject: [PATCH 01/16] fix(webapp): correct error display when error detail is string --- www/webapp/src/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/webapp/src/utils.js b/www/webapp/src/utils.js index 7cb414ccd..29a418c16 100644 --- a/www/webapp/src/utils.js +++ b/www/webapp/src/utils.js @@ -56,7 +56,7 @@ function _digestError(error) { if ('detail' in data) { return [data.detail]; } else if ('non_field_errors' in data) { - return data.non_field_errors; + return Array.isArray(data.non_field_errors) ? data.non_field_errors : [data.non_field_errors]; } else { return data; } From b9abc9e0d8135f330232f6e8ef31ba47a4bb0a91 Mon Sep 17 00:00:00 2001 From: Peter Thomassen Date: Tue, 9 Aug 2022 21:49:30 -0400 Subject: [PATCH 02/16] fix(): update README for a2c259d835c133755e2f10af5ea4b88092ca71e8 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 72d280b41..4869108e4 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Production: Storage ------- -All important data is stored in the databases managed by the `db*` containers. They use Docker volumes which, by default, reside in `/var/lib/docker/volumes/desec-stack_{dbapi_postgres,dblord_mysql,dbmaster_mysql}`. +All important data is stored in the databases managed by the `db*` containers. They use Docker volumes which, by default, reside in `/var/lib/docker/volumes/desec-stack_{dbapi_postgres,dblord_mysql,dbmaster_postgres}`. This is the location you will want to back up. (Be sure to follow standard MySQL/Postgres backup practices, i.e. make sure things are consistent.) API Versions and Roadmap From 9f5e22f2c6ea252f743b3949005a7d590aa9b569 Mon Sep 17 00:00:00 2001 From: Peter Thomassen Date: Wed, 10 Aug 2022 22:51:32 -0400 Subject: [PATCH 03/16] refactor(api): remove dead code --- api/desecapi/models.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/api/desecapi/models.py b/api/desecapi/models.py index 946fc009a..b51232ee0 100644 --- a/api/desecapi/models.py +++ b/api/desecapi/models.py @@ -981,19 +981,6 @@ def _state_fields(self): # re-use after the state has been changed and changed back. return super()._state_fields + [self.user.email, self.user.password, self.user.is_active] - def validate_legacy_state(self, value): - return value == self.state_of(self._state_fields[:-1] + [False]) - - def validate_state(self, value): - is_valid = super().validate_state(value) - - # Retry with state structure before migration 0019 (activation link transition period) - # TODO Remove once legacy links have expired (DESECSTACK_API_AUTHACTION_VALIDITY hours after deployment) - if not is_valid and self._state_fields[-1] is None and self.user.last_login is None: - is_valid = self.validate_legacy_state(value) - - return is_valid - class AuthenticatedActivateUserAction(AuthenticatedUserAction): domain = models.CharField(max_length=191) From 97627bcde8f8b5889eb87e31f6f948e0e1b9cd83 Mon Sep 17 00:00:00 2001 From: Peter Thomassen Date: Mon, 9 May 2022 20:59:03 +0200 Subject: [PATCH 04/16] refactor(api): better logic and template tags for confirmation mails --- .../management/commands/scavenge-unused.py | 7 +-- api/desecapi/models.py | 19 -------- api/desecapi/serializers.py | 46 ++++++++++++++++++- .../emails/activate-account/content.txt | 8 ++-- .../templates/emails/change-email/content.txt | 10 ++-- .../emails/confirm-account/content.txt | 8 ++-- .../emails/delete-account/content.txt | 6 +-- .../templates/emails/renew-domain/content.txt | 12 ++--- .../emails/reset-password/content.txt | 8 ++-- api/desecapi/templatetags/action_extras.py | 19 ++++++++ api/desecapi/views.py | 17 ++++--- 11 files changed, 102 insertions(+), 58 deletions(-) create mode 100644 api/desecapi/templatetags/action_extras.py diff --git a/api/desecapi/management/commands/scavenge-unused.py b/api/desecapi/management/commands/scavenge-unused.py index 2bb8b8d3e..37b2218cc 100644 --- a/api/desecapi/management/commands/scavenge-unused.py +++ b/api/desecapi/management/commands/scavenge-unused.py @@ -49,16 +49,17 @@ def warn_domain_deletion(cls, renewal_state, notice_days, inactive_days): domain_user_map[domain.owner].append(domain) # Prepare and send emails, and keep renewal status in sync - deletion_date = timezone.localdate() + datetime.timedelta(days=notice_days) + context = {'deletion_date': timezone.localdate() + datetime.timedelta(days=notice_days)} for user, domains in domain_user_map.items(): with transaction.atomic(): # Update renewal status of the user's affected domains, but don't commit before sending the email + actions = [] for domain in domains: domain.renewal_state += 1 domain.renewal_changed = timezone.now() domain.save(update_fields=['renewal_state', 'renewal_changed']) - domains = [{'domain': domain} for domain in domains] - user.send_confirmation_email('renew-domain', deletion_date=deletion_date, params=domains) + actions.append(models.AuthenticatedRenewDomainBasicUserAction(user=user, domain=domain)) + serializers.AuthenticatedRenewDomainBasicUserActionSerializer(actions, many=True, context=context).save() @classmethod def delete_domains(cls, inactive_days): diff --git a/api/desecapi/models.py b/api/desecapi/models.py index b51232ee0..28abaf43b 100644 --- a/api/desecapi/models.py +++ b/api/desecapi/models.py @@ -30,7 +30,6 @@ from django.db.models.expressions import RawSQL from django.db.models.functions import Concat, Length from django.template.loader import get_template -from django.urls import resolve, reverse from django.utils import timezone from django_prometheus.models import ExportModelOperationsMixin from dns import rdataclass, rdatatype @@ -195,24 +194,6 @@ def send_email(self, reason, context=None, recipient=None): metrics.get('desecapi_messages_queued').labels(reason, self.pk, lanes[reason]).observe(num_queued) return num_queued - def send_confirmation_email(self, reason, recipient=None, params=None, **kwargs): - def _generate_link(**kwargs): - action = action_serializer.Meta.model(user=self, **kwargs) - action_data = action_serializer(action).data - return f'https://desec.{settings.DESECSTACK_DOMAIN}' + reverse(view_name, args=[action_data['code']]) - - view_name = f'v1:confirm-{reason}' - url = reverse(view_name, args=['code']) # dummy value; parameter is required for reverse URL resolution - action_serializer = resolve(url).func.view_class.serializer_class - if action_serializer.validity_period is not None: - kwargs['link_expiration_hours'] = action_serializer.validity_period // timedelta(hours=1) - - if isinstance(params, list): - kwargs['confirmation_link'] = [(_generate_link(**(param or {})), param) for param in params] - else: - kwargs['confirmation_link'] = (_generate_link(**(params or {})), params) - self.send_email(reason, recipient=recipient, context=dict(kwargs, params=params)) - validate_domain_name = [ validate_lower, diff --git a/api/desecapi/serializers.py b/api/desecapi/serializers.py index 8e84ad61a..06b6540ef 100644 --- a/api/desecapi/serializers.py +++ b/api/desecapi/serializers.py @@ -876,21 +876,54 @@ def save(self, **kwargs): raise ValueError -class AuthenticatedBasicUserActionSerializer(AuthenticatedActionSerializer): +class AuthenticatedBasicUserActionMixin(): + def save(self, **kwargs): + context = {**self.context, 'action_serializer': self} + return self.action_user.send_email(self.reason, context=context, **kwargs) + + +class AuthenticatedBasicUserActionSerializer(AuthenticatedBasicUserActionMixin, AuthenticatedActionSerializer): user = serializers.PrimaryKeyRelatedField( queryset=models.User.objects.all(), error_messages={'does_not_exist': 'This user does not exist.'}, pk_field=serializers.UUIDField() ) + reason = None + class Meta: model = models.AuthenticatedBasicUserAction fields = AuthenticatedActionSerializer.Meta.fields + ('user',) + @property + def action_user(self): + return self.instance.user + + @classmethod + def build_and_save(cls, **kwargs): + action = cls.Meta.model(**kwargs) + return cls(action).save() + + +class AuthenticatedBasicUserActionListSerializer(AuthenticatedBasicUserActionMixin, serializers.ListSerializer): + + @property + def reason(self): + return self.child.reason + + @property + def action_user(self): + user = self.instance[0].user + if any(instance.user != user for instance in self.instance): + raise ValueError('Actions must belong to the same user.') + return user + class AuthenticatedActivateUserActionSerializer(AuthenticatedBasicUserActionSerializer): captcha = CaptchaSolutionSerializer(required=False) + reason = 'activate-account' + class Meta(AuthenticatedBasicUserActionSerializer.Meta): model = models.AuthenticatedActivateUserAction fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ('captcha', 'domain',) @@ -919,12 +952,18 @@ class AuthenticatedChangeEmailUserActionSerializer(AuthenticatedBasicUserActionS required=True, ) + reason = 'change-email' + class Meta(AuthenticatedBasicUserActionSerializer.Meta): model = models.AuthenticatedChangeEmailUserAction fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ('new_email',) + def save(self): + return super().save(recipient=self.instance.new_email) + class AuthenticatedConfirmAccountUserActionSerializer(AuthenticatedBasicUserActionSerializer): + reason = 'confirm-account' validity_period = timedelta(days=14) class Meta(AuthenticatedBasicUserActionSerializer.Meta): @@ -934,12 +973,15 @@ class Meta(AuthenticatedBasicUserActionSerializer.Meta): class AuthenticatedResetPasswordUserActionSerializer(AuthenticatedBasicUserActionSerializer): new_password = serializers.CharField(write_only=True) + reason = 'reset-password' + class Meta(AuthenticatedBasicUserActionSerializer.Meta): model = models.AuthenticatedResetPasswordUserAction fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ('new_password',) class AuthenticatedDeleteUserActionSerializer(AuthenticatedBasicUserActionSerializer): + reason = 'delete-account' class Meta(AuthenticatedBasicUserActionSerializer.Meta): model = models.AuthenticatedDeleteUserAction @@ -957,7 +999,9 @@ class Meta: class AuthenticatedRenewDomainBasicUserActionSerializer(AuthenticatedDomainBasicUserActionSerializer): + reason = 'renew-domain' validity_period = None class Meta(AuthenticatedDomainBasicUserActionSerializer.Meta): model = models.AuthenticatedRenewDomainBasicUserAction + list_serializer_class = AuthenticatedBasicUserActionListSerializer diff --git a/api/desecapi/templates/emails/activate-account/content.txt b/api/desecapi/templates/emails/activate-account/content.txt index ed77c7e3d..2362bdc87 100644 --- a/api/desecapi/templates/emails/activate-account/content.txt +++ b/api/desecapi/templates/emails/activate-account/content.txt @@ -1,17 +1,17 @@ -Hi there, +{% load action_extras %}Hi there, Thank you for registering with deSEC!{% if domain is not None %} You are about to set up the following domain: {{ domain }}{% endif %} {% if domain is None %} As we may need to contact you in the future, you need to verify your email address before you can use your account. To do so, please use -the following link (valid for {{ link_expiration_hours }} hours): +the following link (valid for {% action_link_expiration_hours action_serializer %} hours): {% else %} To create your account and finish the registration, please confirm you -received this email by clicking on the following link (valid for {{ link_expiration_hours }} +received this email by clicking on the following link (valid for {% action_link_expiration_hours action_serializer %} hours): {% endif %} -{{ confirmation_link.0 }} +{% action_link action_serializer %} After that, please follow the instructions on the confirmation page. If link has already expired, please register again. diff --git a/api/desecapi/templates/emails/change-email/content.txt b/api/desecapi/templates/emails/change-email/content.txt index 82002f546..b0d577d8d 100644 --- a/api/desecapi/templates/emails/change-email/content.txt +++ b/api/desecapi/templates/emails/change-email/content.txt @@ -1,19 +1,19 @@ -Hi, +{% load action_extras %}Hi, You requested to change the email address associated with your deSEC account from: -{{ old_email }} +{{ action_serializer.instance.user.email }} to: -{{ new_email }} +{{ action_serializer.instance.new_email }} As we may need to contact you under this address in the future, you need to verify your new email address before we can make the change. -To do so, please use the following link (valid for {{ link_expiration_hours }} hours): +To do so, please use the following link (valid for {% action_link_expiration_hours action_serializer %} hours): -{{ confirmation_link.0 }} +{% action_link action_serializer %} After your confirmation, we will perform the change. diff --git a/api/desecapi/templates/emails/confirm-account/content.txt b/api/desecapi/templates/emails/confirm-account/content.txt index 0c70467c7..a82326513 100644 --- a/api/desecapi/templates/emails/confirm-account/content.txt +++ b/api/desecapi/templates/emails/confirm-account/content.txt @@ -1,6 +1,6 @@ -Hi there, +{% load action_extras %}Hi there, -You have registered with deSEC a long time ago (on {{ created|date:"Y-m-d" }}), probably to +You have registered with deSEC a long time ago (on {{ action_serializer.action_user.created|date:"Y-m-d" }}), probably to set up a DNS domain (e.g. under dedyn.io). At the time when you registered your account, we did not verify your email address. @@ -11,9 +11,9 @@ that all addresses are verified. Therefore, if you would like to continue using your deSEC account, you now need to verify your email address. To do so, please use the following link -(valid for {% widthratio link_expiration_hours 24 1 %} days): +(valid for {% action_link_expiration_hours action_serializer as hours %}{% widthratio hours 24 1 %} days): -{{ confirmation_link.0 }} +{% action_link action_serializer %} If you do not want to continue using deSEC, we will delete your account once the link has expired, without contacting you again. In case you miss the diff --git a/api/desecapi/templates/emails/delete-account/content.txt b/api/desecapi/templates/emails/delete-account/content.txt index 90e4611f5..6d366c029 100644 --- a/api/desecapi/templates/emails/delete-account/content.txt +++ b/api/desecapi/templates/emails/delete-account/content.txt @@ -1,4 +1,4 @@ -Hi there, +{% load action_extras %}Hi there, Sad to see you leave! 😢 We know there's always room for improvement. If your wish to leave deSEC is due to any shortcomings of our service, @@ -6,9 +6,9 @@ please shoot us an email so that we can improve whatever is not right. Otherwise, we will delete your account, including all related data. Before we do so, we need you to confirm once more that this is what you -really, really want by clicking the following link (valid for {{ link_expiration_hours }} hours): +really, really want by clicking the following link (valid for {% action_link_expiration_hours action_serializer %} hours): -{{ confirmation_link.0 }} +{% action_link action_serializer %} Note that this action is irreversible! We cannot recover your account. diff --git a/api/desecapi/templates/emails/renew-domain/content.txt b/api/desecapi/templates/emails/renew-domain/content.txt index a47eeb822..a8831cd7d 100644 --- a/api/desecapi/templates/emails/renew-domain/content.txt +++ b/api/desecapi/templates/emails/renew-domain/content.txt @@ -1,8 +1,8 @@ -Hi there, +{% load action_extras %}Hi there, You are the owner of the following domain name(s): -{% for link in confirmation_link %} - * {{ link.1.domain.name }} +{% for instance in action_serializer.instance %} + * {{ instance.domain.name }} {% endfor %} We noticed that the DNS information of the above domain(s) have not received any updates for more than 6 months. @@ -18,9 +18,9 @@ your account, we will also delete your account on this date. To retain your domain name (and account), either change a DNS record before that date, or click the following link(s): -{% for link in confirmation_link %} - * {{ link.1.domain.name }} - {{ link.0 }} +{% for instance in action_serializer.instance %} + * {{ instance.domain.name }} + {% action_link action_serializer forloop.counter0 %} {% endfor %} In case you have questions, feel free to contact us! diff --git a/api/desecapi/templates/emails/reset-password/content.txt b/api/desecapi/templates/emails/reset-password/content.txt index cb83fc904..fe34871af 100644 --- a/api/desecapi/templates/emails/reset-password/content.txt +++ b/api/desecapi/templates/emails/reset-password/content.txt @@ -1,14 +1,14 @@ -Hi, +{% load action_extras %}Hi, We received a request to reset the password for your deSEC account. This may happen when someone uses the "Reset Password" function on our -web site, or when you created a new account just now that does not yet +website, or when you created a new account just now that does not yet have a password. To ensure that the request is legitimate, we need you to confirm it -using the following link (valid for {{ link_expiration_hours }} hours): +using the following link (valid for {% action_link_expiration_hours action_serializer %} hours): -{{ confirmation_link.0 }} +{% action_link action_serializer %} After your confirmation, you can provide your new password. diff --git a/api/desecapi/templatetags/action_extras.py b/api/desecapi/templatetags/action_extras.py new file mode 100644 index 000000000..9d7bf1845 --- /dev/null +++ b/api/desecapi/templatetags/action_extras.py @@ -0,0 +1,19 @@ +from datetime import timedelta + +from django import template +from django.conf import settings +from django.urls import reverse + +register = template.Library() + + +@register.simple_tag +def action_link(action_serializer, idx=None): + view_name = f'v1:confirm-{action_serializer.reason}' + code = action_serializer.data['code'] if idx is None else action_serializer.data[idx]['code'] + return f'https://desec.{settings.DESECSTACK_DOMAIN}' + reverse(view_name, args=[code]) + + +@register.simple_tag +def action_link_expiration_hours(action_serializer): + return action_serializer.validity_period // timedelta(hours=1) diff --git a/api/desecapi/views.py b/api/desecapi/views.py index 8e7560b23..3b30b238a 100644 --- a/api/desecapi/views.py +++ b/api/desecapi/views.py @@ -515,7 +515,7 @@ def create(self, request, *args, **kwargs): # send email if needed domain = serializer.validated_data.get('domain') if domain or activation_required: - user.send_confirmation_email('activate-account', params=dict(domain=domain)) + serializers.AuthenticatedActivateUserActionSerializer.build_and_save(user=user, domain=domain) # This request is unauthenticated, so don't expose whether we did anything. message = 'Welcome! Please check your mailbox.' if activation_required else 'Welcome!' @@ -543,7 +543,7 @@ class AccountDeleteView(APIView): def post(self, request, *args, **kwargs): if request.user.domains.exists(): return self.response_still_has_domains - request.user.send_confirmation_email('delete-account') + serializers.AuthenticatedDeleteUserActionSerializer.build_and_save(user=request.user) return Response(data={'detail': 'Please check your mailbox for further account deletion instructions.'}, status=status.HTTP_202_ACCEPTED) @@ -589,8 +589,7 @@ def post(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) new_email = serializer.validated_data['new_email'] - request.user.send_confirmation_email('change-email', recipient=new_email, old_email=request.user.email, - params=dict(new_email=new_email)) + serializers.AuthenticatedChangeEmailUserActionSerializer.build_and_save(user=request.user, new_email=new_email) # At this point, we know that we are talking to the user, so we can tell that we sent an email. return Response(data={'detail': 'Please check your mailbox to confirm email address change.'}, @@ -610,7 +609,7 @@ def post(self, request, *args, **kwargs): except models.User.DoesNotExist: pass else: - user.send_confirmation_email('reset-password') + serializers.AuthenticatedResetPasswordUserActionSerializer.build_and_save(user=user) # This request is unauthenticated, so don't expose whether we did anything. return Response(data={'detail': 'Please check your mailbox for further password reset instructions. ' @@ -705,7 +704,7 @@ def _create_domain(self): try: serializer.is_valid(raise_exception=True) except ValidationError as e: # e.g. domain name unavailable - self.authenticated_action.user.delete() + self.request.user.delete() reasons = ', '.join([detail.code for detail in e.detail.get('name', [])]) raise ValidationError( f'The requested domain {self.authenticated_action.domain} could not be registered (reason: {reasons}). ' @@ -713,11 +712,11 @@ def _create_domain(self): ) # TODO the following line is subject to race condition and can fail, as for the domain name, we have that # time-of-check != time-of-action - return PDNSChangeTracker.track(lambda: serializer.save(owner=self.authenticated_action.user)) + return PDNSChangeTracker.track(lambda: serializer.save(owner=self.request.user)) def _finalize_without_domain(self): - if not is_password_usable(self.authenticated_action.user.password): - self.authenticated_action.user.send_confirmation_email('reset-password') + if not is_password_usable(self.request.user.password): + serializers.AuthenticatedResetPasswordUserActionSerializer.build_and_save(user=self.request.user) return Response({'detail': 'Success! We sent you instructions on how to set your password.'}) return Response({'detail': 'Success! Your account has been activated, and you can now log in.'}) From 4900c0cdc3cd0814ab6e49ab243c4592cc704128 Mon Sep 17 00:00:00 2001 From: Peter Thomassen Date: Wed, 10 Aug 2022 18:15:48 -0400 Subject: [PATCH 05/16] refactor(api): use template inheritance for confirmation emails --- api/desecapi/models.py | 3 +-- api/desecapi/templates/emails/activate-account/content.txt | 4 +++- .../emails/change-email-confirmation-old-email/content.txt | 4 +++- api/desecapi/templates/emails/change-email/content.txt | 4 +++- api/desecapi/templates/emails/confirm-account/content.txt | 4 +++- api/desecapi/templates/emails/{footer.txt => content.txt} | 5 +++-- api/desecapi/templates/emails/delete-account/content.txt | 4 +++- api/desecapi/templates/emails/donation/donor-content.txt | 4 +++- .../emails/password-change-confirmation/content.txt | 4 +++- api/desecapi/templates/emails/renew-domain/content.txt | 4 +++- api/desecapi/templates/emails/reset-password/content.txt | 4 +++- api/desecapi/views.py | 3 +-- 12 files changed, 32 insertions(+), 15 deletions(-) rename api/desecapi/templates/emails/{footer.txt => content.txt} (75%) diff --git a/api/desecapi/models.py b/api/desecapi/models.py index 28abaf43b..92919fb36 100644 --- a/api/desecapi/models.py +++ b/api/desecapi/models.py @@ -181,12 +181,11 @@ def send_email(self, reason, context=None, recipient=None): context = context or {} content = get_template(f'emails/{reason}/content.txt').render(context) content += f'\nSupport Reference: user_id = {self.pk}\n' - footer = get_template('emails/footer.txt').render() logger.warning(f'Queuing email for user account {self.pk} (reason: {reason}, lane: {lanes[reason]})') num_queued = EmailMessage( subject=get_template(f'emails/{reason}/subject.txt').render(context).strip(), - body=content + footer, + body=content, from_email=get_template('emails/from.txt').render(), to=[recipient or self.email], connection=get_connection(lane=lanes[reason], debug={'user': self.pk, 'reason': reason}) diff --git a/api/desecapi/templates/emails/activate-account/content.txt b/api/desecapi/templates/emails/activate-account/content.txt index 2362bdc87..602ebc38b 100644 --- a/api/desecapi/templates/emails/activate-account/content.txt +++ b/api/desecapi/templates/emails/activate-account/content.txt @@ -1,4 +1,5 @@ -{% load action_extras %}Hi there, +{% extends "emails/content.txt" %} +{% block content %}{% load action_extras %}Hi there, Thank you for registering with deSEC!{% if domain is not None %} You are about to set up the following domain: {{ domain }}{% endif %} @@ -25,3 +26,4 @@ We hope you enjoy deSEC! Stay secure, Nils +{% endblock %} diff --git a/api/desecapi/templates/emails/change-email-confirmation-old-email/content.txt b/api/desecapi/templates/emails/change-email-confirmation-old-email/content.txt index afb884485..1e9294acb 100644 --- a/api/desecapi/templates/emails/change-email-confirmation-old-email/content.txt +++ b/api/desecapi/templates/emails/change-email-confirmation-old-email/content.txt @@ -1,4 +1,5 @@ -Hi there, +{% extends "emails/content.txt" %} +{% block content %}Hi there, We're writing to let you know that the email address associated with your deSEC account has been changed to another address. @@ -8,3 +9,4 @@ support@desec.io. Stay secure, The deSEC Team +{% endblock %} \ No newline at end of file diff --git a/api/desecapi/templates/emails/change-email/content.txt b/api/desecapi/templates/emails/change-email/content.txt index b0d577d8d..0c0042436 100644 --- a/api/desecapi/templates/emails/change-email/content.txt +++ b/api/desecapi/templates/emails/change-email/content.txt @@ -1,4 +1,5 @@ -{% load action_extras %}Hi, +{% extends "emails/content.txt" %} +{% block content %}{% load action_extras %}Hi, You requested to change the email address associated with your deSEC account from: @@ -19,3 +20,4 @@ After your confirmation, we will perform the change. Stay secure, The deSEC Team +{% endblock %} diff --git a/api/desecapi/templates/emails/confirm-account/content.txt b/api/desecapi/templates/emails/confirm-account/content.txt index a82326513..30b89ac57 100644 --- a/api/desecapi/templates/emails/confirm-account/content.txt +++ b/api/desecapi/templates/emails/confirm-account/content.txt @@ -1,4 +1,5 @@ -{% load action_extras %}Hi there, +{% extends "emails/content.txt" %} +{% block content %}{% load action_extras %}Hi there, You have registered with deSEC a long time ago (on {{ action_serializer.action_user.created|date:"Y-m-d" }}), probably to set up a DNS domain (e.g. under dedyn.io). At the time when you registered @@ -23,3 +24,4 @@ We apologize for the slight inconvenience, and hope you enjoy deSEC! Stay secure, Nils +{% endblock %} diff --git a/api/desecapi/templates/emails/footer.txt b/api/desecapi/templates/emails/content.txt similarity index 75% rename from api/desecapi/templates/emails/footer.txt rename to api/desecapi/templates/emails/content.txt index dd6cdffcb..f69695050 100644 --- a/api/desecapi/templates/emails/footer.txt +++ b/api/desecapi/templates/emails/content.txt @@ -1,5 +1,5 @@ - --- +{% block content %}{% endblock %} +-- Like our community service? 💛 Please consider donating at @@ -12,3 +12,4 @@ Germany Vorstandsvorsitz: Nils Wisiol Registergericht: AG Berlin (Charlottenburg) VR 37525 +{% block trailer %}{% endblock %} \ No newline at end of file diff --git a/api/desecapi/templates/emails/delete-account/content.txt b/api/desecapi/templates/emails/delete-account/content.txt index 6d366c029..407bb20fe 100644 --- a/api/desecapi/templates/emails/delete-account/content.txt +++ b/api/desecapi/templates/emails/delete-account/content.txt @@ -1,4 +1,5 @@ -{% load action_extras %}Hi there, +{% extends "emails/content.txt" %} +{% block content %}{% load action_extras %}Hi there, Sad to see you leave! 😢 We know there's always room for improvement. If your wish to leave deSEC is due to any shortcomings of our service, @@ -14,3 +15,4 @@ Note that this action is irreversible! We cannot recover your account. Wherever you go, stay secure! The deSEC Team +{% endblock %} diff --git a/api/desecapi/templates/emails/donation/donor-content.txt b/api/desecapi/templates/emails/donation/donor-content.txt index 906f55af0..0ee8a9359 100644 --- a/api/desecapi/templates/emails/donation/donor-content.txt +++ b/api/desecapi/templates/emails/donation/donor-content.txt @@ -1,4 +1,5 @@ -Dear supporter, +{% extends "emails/content.txt" %} +{% block content %}Dear supporter, We hereby confirm your donation to deSEC. We would like to THANK YOU for your support. If you have any questions concerning your donation, how @@ -16,3 +17,4 @@ Again, thank you so much. Cheers, Nils +{% endblock %} diff --git a/api/desecapi/templates/emails/password-change-confirmation/content.txt b/api/desecapi/templates/emails/password-change-confirmation/content.txt index 511e65ef7..53ffb9a20 100644 --- a/api/desecapi/templates/emails/password-change-confirmation/content.txt +++ b/api/desecapi/templates/emails/password-change-confirmation/content.txt @@ -1,4 +1,5 @@ -Hi, +{% extends "emails/content.txt" %} +{% block content %}Hi, This is to let you know that the password for your deSEC account has been changed. @@ -8,3 +9,4 @@ support@desec.io. Stay secure, The deSEC Team +{% endblock %} diff --git a/api/desecapi/templates/emails/renew-domain/content.txt b/api/desecapi/templates/emails/renew-domain/content.txt index a8831cd7d..813eec216 100644 --- a/api/desecapi/templates/emails/renew-domain/content.txt +++ b/api/desecapi/templates/emails/renew-domain/content.txt @@ -1,4 +1,5 @@ -{% load action_extras %}Hi there, +{% extends "emails/content.txt" %} +{% block content %}{% load action_extras %}Hi there, You are the owner of the following domain name(s): {% for instance in action_serializer.instance %} @@ -26,3 +27,4 @@ In case you have questions, feel free to contact us! Stay secure, The deSEC Team +{% endblock %} diff --git a/api/desecapi/templates/emails/reset-password/content.txt b/api/desecapi/templates/emails/reset-password/content.txt index fe34871af..58e9f859d 100644 --- a/api/desecapi/templates/emails/reset-password/content.txt +++ b/api/desecapi/templates/emails/reset-password/content.txt @@ -1,4 +1,5 @@ -{% load action_extras %}Hi, +{% extends "emails/content.txt" %} +{% block content %}{% load action_extras %}Hi, We received a request to reset the password for your deSEC account. This may happen when someone uses the "Reset Password" function on our @@ -14,3 +15,4 @@ After your confirmation, you can provide your new password. Stay secure, The deSEC Team +{% endblock %} diff --git a/api/desecapi/views.py b/api/desecapi/views.py index 3b30b238a..ef951d282 100644 --- a/api/desecapi/views.py +++ b/api/desecapi/views.py @@ -480,9 +480,8 @@ def perform_create(self, serializer): if instance.email: content_tmpl = get_template('emails/donation/donor-content.txt') subject_tmpl = get_template('emails/donation/donor-subject.txt') - footer_tmpl = get_template('emails/footer.txt') email = EmailMessage(subject_tmpl.render(context), - content_tmpl.render(context) + footer_tmpl.render(), + content_tmpl.render(context), from_tmpl.render(context), [instance.email]) email.send() From 8bae2c5a04dca97e0b762e60e5e8653d51279a53 Mon Sep 17 00:00:00 2001 From: Peter Thomassen Date: Wed, 12 Jan 2022 14:00:46 +0100 Subject: [PATCH 06/16] feat(api): add User.outreach_preference --- .../0022_user_outreach_preference.py | 18 +++++++++ api/desecapi/models.py | 1 + api/desecapi/serializers.py | 4 +- api/desecapi/tests/test_user_management.py | 39 ++++++++++++++++--- api/desecapi/views.py | 2 +- 5 files changed, 55 insertions(+), 9 deletions(-) create mode 100644 api/desecapi/migrations/0022_user_outreach_preference.py diff --git a/api/desecapi/migrations/0022_user_outreach_preference.py b/api/desecapi/migrations/0022_user_outreach_preference.py new file mode 100644 index 000000000..a5390c64f --- /dev/null +++ b/api/desecapi/migrations/0022_user_outreach_preference.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.1 on 2022-01-11 19:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('desecapi', '0021_authenticatednoopuseraction'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='outreach_preference', + field=models.BooleanField(default=True), + ), + ] diff --git a/api/desecapi/models.py b/api/desecapi/models.py index 92919fb36..78a9fd0ee 100644 --- a/api/desecapi/models.py +++ b/api/desecapi/models.py @@ -103,6 +103,7 @@ def _limit_domains_default(): created = models.DateTimeField(auto_now_add=True) limit_domains = models.PositiveIntegerField(default=_limit_domains_default.__func__, null=True, blank=True) needs_captcha = models.BooleanField(default=True) + outreach_preference = models.BooleanField(default=True) objects = MyUserManager() diff --git a/api/desecapi/serializers.py b/api/desecapi/serializers.py index 06b6540ef..da49de0d6 100644 --- a/api/desecapi/serializers.py +++ b/api/desecapi/serializers.py @@ -721,7 +721,7 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = models.User - fields = ('created', 'email', 'id', 'limit_domains',) + fields = ('created', 'email', 'id', 'limit_domains', 'outreach_preference',) read_only_fields = ('created', 'email', 'id', 'limit_domains',) def validate_password(self, value): @@ -739,7 +739,7 @@ class RegisterAccountSerializer(UserSerializer): class Meta: model = UserSerializer.Meta.model - fields = ('email', 'password', 'domain', 'captcha',) + fields = ('email', 'password', 'domain', 'captcha', 'outreach_preference',) extra_kwargs = { 'password': { 'write_only': True, # Do not expose password field diff --git a/api/desecapi/tests/test_user_management.py b/api/desecapi/tests/test_user_management.py index 857612ea6..a8f333135 100644 --- a/api/desecapi/tests/test_user_management.py +++ b/api/desecapi/tests/test_user_management.py @@ -417,6 +417,7 @@ def _test_registration(self, email=None, password=None, late_captcha=True, **kwa self.assertFalse(User.objects.get(email=email).is_active) self.assertIsNone(User.objects.get(email=email).is_active) self.assertEqual(User.objects.get(email=email).needs_captcha, late_captcha) + self.assertEqual(User.objects.get(email=email).outreach_preference, kwargs.get('outreach_preference', True)) self.assertPassword(email, password) confirmation_link = self.assertRegistrationEmail(email) self.assertConfirmationLinkRedirect(confirmation_link) @@ -539,7 +540,9 @@ def test_authenticated_action_redirect_with_invalid_code(self): self.assertConfirmationLinkRedirect(confirmation_link) def test_registration(self): - self._test_registration(password=self.random_password()) + for outreach_preference in [None, True, False]: + kwargs = dict(outreach_preference=outreach_preference) if outreach_preference is not None else {} + self._test_registration(password=self.random_password(), **kwargs) def test_registration_trim_email(self): user_email = ' {} '.format(self.random_username()) @@ -685,15 +688,14 @@ def _finish_delete_account(self, confirmation_link): def test_view_account(self): response = self.client.view_account(self.token) self.assertEqual(response.status_code, 200) - self.assertEqual(response.data.keys(), {'created', 'email', 'id', 'limit_domains'}) + self.assertEqual(response.data.keys(), {'created', 'email', 'id', 'limit_domains', 'outreach_preference'}) self.assertEqual(response.data['email'], self.email) self.assertEqual(response.data['id'], str(User.objects.get(email=self.email).pk)) self.assertEqual(response.data['limit_domains'], settings.LIMIT_USER_DOMAIN_COUNT_DEFAULT) + self.assertTrue(response.data['outreach_preference']) - def test_view_account_read_only(self): - # Should this test ever be removed (to allow writeable fields), make sure to - # add new tests for each read-only field individually (such as limit_domains)! - for method in [self.client.patch, self.client.put, self.client.post, self.client.delete]: + def test_view_account_forbidden_methods(self): + for method in [self.client.post, self.client.delete]: response = method( reverse('v1:account'), {'limit_domains': 99}, @@ -701,6 +703,31 @@ def test_view_account_read_only(self): ) self.assertResponse(response, status.HTTP_405_METHOD_NOT_ALLOWED) + def test_view_account_update(self): + user = User.objects.get(email=self.email) + immutable_fields = ('created', 'email', 'id', 'limit_domains', 'password',) + immutable_values = [getattr(user, key) for key in immutable_fields] + outreach_preference = user.outreach_preference + + for method in [self.client.patch, self.client.put]: + outreach_preference = not outreach_preference + response = method( + reverse('v1:account'), + { + 'created': '2019-10-16T18:09:17.715702Z', + 'email': 'youremailaddress@example.com', + 'id': '9ab16e5c-805d-4ab1-9030-af3f5a541d47', + 'limit_domains': 42, + 'password': self.random_password(), + 'outreach_preference': outreach_preference, + }, + HTTP_AUTHORIZATION='Token {}'.format(self.token) + ) + self.assertResponse(response, status.HTTP_200_OK) + user = User.objects.get(email=self.email) + self.assertEqual(outreach_preference, user.outreach_preference) # `outreach_preference` updated + self.assertEqual(immutable_values, [getattr(user, k) for k in immutable_fields]) # read-only fields ignored + def test_reset_password(self): self._test_reset_password(self.email) diff --git a/api/desecapi/views.py b/api/desecapi/views.py index ef951d282..f2dadd1b0 100644 --- a/api/desecapi/views.py +++ b/api/desecapi/views.py @@ -521,7 +521,7 @@ def create(self, request, *args, **kwargs): return Response(data={'detail': message}, status=status.HTTP_202_ACCEPTED) -class AccountView(generics.RetrieveAPIView): +class AccountView(generics.RetrieveUpdateAPIView): permission_classes = (IsAuthenticated, permissions.TokenNoDomainPolicy,) serializer_class = serializers.UserSerializer throttle_scope = 'account_management_passive' From 9d6da391231de0695f790ad68e8263bb71260b41 Mon Sep 17 00:00:00 2001 From: Peter Thomassen Date: Wed, 19 Jan 2022 17:47:51 +0100 Subject: [PATCH 07/16] refactor(api): add AuthenticatedEmailUserAction in action hierarchy --- .../0023_authenticatedemailuseraction.py | 24 +++++++++++++++++++ api/desecapi/models.py | 17 +++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 api/desecapi/migrations/0023_authenticatedemailuseraction.py diff --git a/api/desecapi/migrations/0023_authenticatedemailuseraction.py b/api/desecapi/migrations/0023_authenticatedemailuseraction.py new file mode 100644 index 000000000..272f76963 --- /dev/null +++ b/api/desecapi/migrations/0023_authenticatedemailuseraction.py @@ -0,0 +1,24 @@ +# Generated by Django 4.0.1 on 2022-01-19 16:00 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('desecapi', '0022_user_outreach_preference'), + ] + + operations = [ + migrations.CreateModel( + name='AuthenticatedEmailUserAction', + fields=[ + ('authenticatedbasicuseraction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='desecapi.authenticatedbasicuseraction')), + ], + options={ + 'managed': False, + }, + bases=('desecapi.authenticatedbasicuseraction',), + ), + ] diff --git a/api/desecapi/models.py b/api/desecapi/models.py index 78a9fd0ee..69785d363 100644 --- a/api/desecapi/models.py +++ b/api/desecapi/models.py @@ -948,7 +948,20 @@ def _state_fields(self): return super()._state_fields + [str(self.user.id)] -class AuthenticatedUserAction(AuthenticatedBasicUserAction): +class AuthenticatedEmailUserAction(AuthenticatedBasicUserAction): + """ + Abstract AuthenticatedAction involving a user instance with unmodified email address. + """ + + class Meta: + managed = False + + @property + def _state_fields(self): + return super()._state_fields + [self.user.email] + + +class AuthenticatedUserAction(AuthenticatedEmailUserAction): """ Abstract AuthenticatedBasicUserAction, incorporating the user's id, email, password, and is_active flag into the Message Authentication Code state. @@ -960,7 +973,7 @@ class Meta: def _state_fields(self): # TODO consider adding a "last change" attribute of the user to the state to avoid code # re-use after the state has been changed and changed back. - return super()._state_fields + [self.user.email, self.user.password, self.user.is_active] + return super()._state_fields + [self.user.password, self.user.is_active] class AuthenticatedActivateUserAction(AuthenticatedUserAction): From 25737a4c68f9a41b22af790aa0365495ba666a11 Mon Sep 17 00:00:00 2001 From: Peter Thomassen Date: Thu, 20 Jan 2022 12:19:15 +0100 Subject: [PATCH 08/16] feat(api): add AuthenticatedChangeOutreachPreferenceUserAction and view --- ...catedchangeoutreachpreferenceuseraction.py | 25 +++++++++++++++++++ api/desecapi/models.py | 12 +++++++++ api/desecapi/serializers.py | 9 +++++++ .../change-outreach-preference/content.txt | 5 ++++ api/desecapi/urls/version_1.py | 1 + api/desecapi/views.py | 9 +++++++ 6 files changed, 61 insertions(+) create mode 100644 api/desecapi/migrations/0024_authenticatedchangeoutreachpreferenceuseraction.py create mode 100644 api/desecapi/templates/emails/change-outreach-preference/content.txt diff --git a/api/desecapi/migrations/0024_authenticatedchangeoutreachpreferenceuseraction.py b/api/desecapi/migrations/0024_authenticatedchangeoutreachpreferenceuseraction.py new file mode 100644 index 000000000..f7c833737 --- /dev/null +++ b/api/desecapi/migrations/0024_authenticatedchangeoutreachpreferenceuseraction.py @@ -0,0 +1,25 @@ +# Generated by Django 4.0.1 on 2022-01-20 11:21 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('desecapi', '0023_authenticatedemailuseraction'), + ] + + operations = [ + migrations.CreateModel( + name='AuthenticatedChangeOutreachPreferenceUserAction', + fields=[ + ('authenticatedemailuseraction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='desecapi.authenticatedemailuseraction')), + ('outreach_preference', models.BooleanField()), + ], + options={ + 'managed': False, + }, + bases=('desecapi.authenticatedemailuseraction',), + ), + ] diff --git a/api/desecapi/models.py b/api/desecapi/models.py index 69785d363..135dbb092 100644 --- a/api/desecapi/models.py +++ b/api/desecapi/models.py @@ -169,6 +169,7 @@ def send_email(self, reason, context=None, recipient=None): 'activate-account': slow_lane, 'change-email': slow_lane, 'change-email-confirmation-old-email': fast_lane, + 'change-outreach-preference': slow_lane, 'confirm-account': slow_lane, 'password-change-confirmation': fast_lane, 'reset-password': fast_lane, @@ -961,6 +962,17 @@ def _state_fields(self): return super()._state_fields + [self.user.email] +class AuthenticatedChangeOutreachPreferenceUserAction(AuthenticatedEmailUserAction): + outreach_preference = models.BooleanField(default=False) + + class Meta: + managed = False + + def _act(self): + self.user.outreach_preference = self.outreach_preference + self.user.save() + + class AuthenticatedUserAction(AuthenticatedEmailUserAction): """ Abstract AuthenticatedBasicUserAction, incorporating the user's id, email, password, and is_active flag into the diff --git a/api/desecapi/serializers.py b/api/desecapi/serializers.py index da49de0d6..fbb8d0285 100644 --- a/api/desecapi/serializers.py +++ b/api/desecapi/serializers.py @@ -919,6 +919,15 @@ def action_user(self): return user +class AuthenticatedChangeOutreachPreferenceUserActionSerializer(AuthenticatedBasicUserActionSerializer): + reason = 'change-outreach-preference' + validity_period = None + + class Meta: + model = models.AuthenticatedChangeOutreachPreferenceUserAction + fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ('outreach_preference',) + + class AuthenticatedActivateUserActionSerializer(AuthenticatedBasicUserActionSerializer): captcha = CaptchaSolutionSerializer(required=False) diff --git a/api/desecapi/templates/emails/change-outreach-preference/content.txt b/api/desecapi/templates/emails/change-outreach-preference/content.txt new file mode 100644 index 000000000..bbad17eb0 --- /dev/null +++ b/api/desecapi/templates/emails/change-outreach-preference/content.txt @@ -0,0 +1,5 @@ +{% extends "emails/content.txt" %} +{% block trailer %} +Unsubscribe: +{% load action_extras %}{% action_link action_serializer %} +{% endblock %} diff --git a/api/desecapi/urls/version_1.py b/api/desecapi/urls/version_1.py index 26a5ccf37..b47c3b0d8 100644 --- a/api/desecapi/urls/version_1.py +++ b/api/desecapi/urls/version_1.py @@ -55,6 +55,7 @@ # Authenticated Actions path('v/activate-account//', views.AuthenticatedActivateUserActionView.as_view(), name='confirm-activate-account'), path('v/change-email//', views.AuthenticatedChangeEmailUserActionView.as_view(), name='confirm-change-email'), + path('v/change-outreach-preference//', views.AuthenticatedChangeOutreachPreferenceUserActionView.as_view(), name='confirm-change-outreach-preference'), path('v/confirm-account//', views.AuthenticatedConfirmAccountUserAction.as_view(), name='confirm-confirm-account'), path('v/reset-password//', views.AuthenticatedResetPasswordUserActionView.as_view(), name='confirm-reset-password'), path('v/delete-account//', views.AuthenticatedDeleteUserActionView.as_view(), name='confirm-delete-account'), diff --git a/api/desecapi/views.py b/api/desecapi/views.py index f2dadd1b0..e204e67f9 100644 --- a/api/desecapi/views.py +++ b/api/desecapi/views.py @@ -682,6 +682,15 @@ def post(self, request, *args, **kwargs): return Response(status=status.HTTP_202_ACCEPTED) +class AuthenticatedChangeOutreachPreferenceUserActionView(AuthenticatedActionView): + html_url = '/confirm/change-outreach-preference/{code}/' + serializer_class = serializers.AuthenticatedChangeOutreachPreferenceUserActionSerializer + + def post(self, request, *args, **kwargs): + super().post(request, *args, **kwargs) + return Response({'detail': 'Thank you! We have recorded that you would not like to receive outreach messages.'}) + + class AuthenticatedActivateUserActionView(AuthenticatedActionView): html_url = '/confirm/activate-account/{code}/' permission_classes = () # don't require that user is activated already From 7de41c0bbc41ea377a2d39062b6d8894fd5ffad0 Mon Sep 17 00:00:00 2001 From: Peter Thomassen Date: Thu, 20 Jan 2022 16:22:06 +0100 Subject: [PATCH 09/16] feat(webapp): sign-up: add outreach preference checkbox --- www/webapp/src/views/SignUp.vue | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/www/webapp/src/views/SignUp.vue b/www/webapp/src/views/SignUp.vue index 16d4dc6f3..97219c454 100644 --- a/www/webapp/src/views/SignUp.vue +++ b/www/webapp/src/views/SignUp.vue @@ -121,14 +121,30 @@ + + + + + +