accounts: send validation email before self-triggered account deletion (#27823)
This commit is contained in:
parent
588dcfb95c
commit
62441e2340
|
@ -278,6 +278,9 @@ default_settings = dict(
|
|||
A2_EMAIL_CHANGE_TOKEN_LIFETIME=Setting(
|
||||
default=7200,
|
||||
definition='Lifetime in seconds of the token sent to verify email adresses'),
|
||||
A2_DELETION_REQUEST_LIFETIME=Setting(
|
||||
default=48*3600,
|
||||
definition='Lifetime in seconds of the user account deletion request'),
|
||||
A2_REDIRECT_WHITELIST=Setting(
|
||||
default=(),
|
||||
definition='List of origins which are authorized to ask for redirection.'),
|
||||
|
|
|
@ -1 +1 @@
|
|||
{% load i18n %}{% autoescape off %}{% blocktrans %}Account deletion request on {{ site }}{% endblocktrans %}{% endautoescape %}
|
||||
{% load i18n %}{% autoescape off %}{% blocktrans %}Account deletion on {{ site }}{% endblocktrans %}{% endautoescape %}
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
{% load i18n %}
|
||||
<html>
|
||||
<body style="max-width: 90ex">
|
||||
<p>{% blocktrans %}{{ full_name }},{% endblocktrans %}</p>
|
||||
<p>
|
||||
{% blocktrans %}
|
||||
Please click on {{ deletion_url }}
|
||||
if you want to validate your account deletion request on
|
||||
{{ site }}.
|
||||
If so, all related data will be deleted in the next few hours.
|
||||
You won't be able to log in with this account anymore.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,10 @@
|
|||
{% load i18n %}{% autoescape off %}{% blocktrans %}{{ full_name }},{% endblocktrans %}
|
||||
|
||||
{% blocktrans %}
|
||||
Please click on {{ deletion_url }}
|
||||
if you want to validate your account deletion request on
|
||||
{{ site }}.
|
||||
If so, all related data will be deleted in the next few hours.
|
||||
You won't be able to log in with this account anymore.
|
||||
{% endblocktrans %}
|
||||
{% endautoescape %}
|
|
@ -0,0 +1 @@
|
|||
{% load i18n %}{% autoescape off %}{% blocktrans %}Validate account deletion request on {{ site }}{% endblocktrans %}{% endautoescape %}
|
|
@ -15,9 +15,10 @@
|
|||
{% block content %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<p>{% trans "Delete my account and all my personal datas ?" %}</p>
|
||||
{{ form.as_p }}
|
||||
<button class="delete-button" name="submit">{% trans "Delete" %}</button>
|
||||
<p>
|
||||
{% trans "Send an account-deletion validation code?" %}
|
||||
</p>
|
||||
<button class="submit-button" name="submit">{% trans "Send the code" %}</button>
|
||||
<button class="cancel-button" name="cancel" formnovalidate>{% trans "Cancel" %}</button>
|
||||
</form>
|
||||
{% endblock %}
|
|
@ -0,0 +1,27 @@
|
|||
{% extends "authentic2/base-page.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block page-title %}
|
||||
{{ block.super }} - {{ view.title }}
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
<a href="{% url "account_management" %}">{% trans "Your account" %}</a>
|
||||
<a href="#">{{ view.title }}</a>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
{% blocktrans with full_name=user.get_full_name %}
|
||||
You are about to delete the account of <strong>{{ full_name }}</strong>.
|
||||
This will remove all related personal data and you won't be able to log in with this account anymore.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<button class="delete-button" name="delete">{% trans "Confirm deletion" %}</button>
|
||||
<button class="cancel-button" name="cancel" formnovalidate>{% trans "Cancel" %}</button>
|
||||
</form>
|
||||
{% endblock %}
|
|
@ -44,6 +44,9 @@ accounts_urlpatterns = [
|
|||
url(r'^delete/$',
|
||||
login_required(views.DeleteView.as_view()),
|
||||
name='delete_account'),
|
||||
url(r'validate-deletion/(?P<deletion_token>[\w: -]+)/$',
|
||||
views.ValidateDeletionView.as_view(),
|
||||
name='validate_deletion'),
|
||||
url(r'^logged-in/$',
|
||||
views.logged_in,
|
||||
name='logged-in'),
|
||||
|
|
|
@ -682,6 +682,15 @@ def build_activation_url(request, email, next_url=None, ou=None, **kwargs):
|
|||
return activate_url
|
||||
|
||||
|
||||
def build_deletion_url(request, **kwargs):
|
||||
data = kwargs.copy()
|
||||
data['user_pk'] = request.user.pk
|
||||
deletion_token = signing.dumps(data)
|
||||
delete_url = request.build_absolute_uri(
|
||||
reverse('validate_deletion', kwargs={'deletion_token': deletion_token}))
|
||||
return delete_url
|
||||
|
||||
|
||||
def send_registration_mail(request, email, ou, template_names=None, next_url=None, context=None,
|
||||
**kwargs):
|
||||
'''Send a registration mail to an user. All given kwargs will be used
|
||||
|
@ -728,6 +737,26 @@ def send_registration_mail(request, email, ou, template_names=None, next_url=Non
|
|||
registration_url)
|
||||
|
||||
|
||||
def send_account_deletion_code(request, user):
|
||||
'''Send an account deletion notification code to a user.
|
||||
|
||||
Can raise an smtplib.SMTPException
|
||||
'''
|
||||
logger = logging.getLogger(__name__)
|
||||
deletion_url = build_deletion_url(request)
|
||||
context = {
|
||||
'full_name': request.user.get_full_name(),
|
||||
'user': request.user,
|
||||
'site': request.get_host(),
|
||||
'deletion_url': deletion_url}
|
||||
template_names = [
|
||||
'authentic2/account_deletion_code']
|
||||
if user.ou:
|
||||
template_names.insert(0, 'authentic2/account_deletion_code_%s' % user.ou.slug)
|
||||
send_templated_mail(user.email, template_names, context, request=request)
|
||||
logger.info(u'account deletion code sent to %s', user.email)
|
||||
|
||||
|
||||
def send_account_deletion_mail(request, user):
|
||||
'''Send an account deletion notification mail to a user.
|
||||
|
||||
|
|
|
@ -1060,10 +1060,12 @@ class RegistrationCompletionView(CreateView):
|
|||
request=self.request)
|
||||
|
||||
|
||||
class DeleteView(FormView):
|
||||
template_name = 'authentic2/accounts_delete.html'
|
||||
success_url = reverse_lazy('auth_logout')
|
||||
title = _('Delete account')
|
||||
registration_completion = valid_token(RegistrationCompletionView.as_view())
|
||||
|
||||
|
||||
class DeleteView(TemplateView):
|
||||
template_name = 'authentic2/accounts_delete_request.html'
|
||||
title = _('Request account deletion')
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not app_settings.A2_REGISTRATION_CAN_DELETE_ACCOUNT:
|
||||
|
@ -1073,32 +1075,66 @@ class DeleteView(FormView):
|
|||
def post(self, request, *args, **kwargs):
|
||||
if 'cancel' in request.POST:
|
||||
return utils.redirect(request, 'account_management')
|
||||
return super(DeleteView, self).post(request, *args, **kwargs)
|
||||
utils.send_account_deletion_code(self.request, self.request.user)
|
||||
messages.info(request,
|
||||
_("An account deletion validation email has been sent to your email address."))
|
||||
return utils.redirect(request, 'account_management')
|
||||
|
||||
def get_form_class(self):
|
||||
if self.request.user.has_usable_password():
|
||||
return profile_forms.DeleteAccountForm
|
||||
return Form
|
||||
|
||||
def get_form_kwargs(self, **kwargs):
|
||||
kwargs = super(DeleteView, self).get_form_kwargs(**kwargs)
|
||||
if self.request.user.has_usable_password():
|
||||
kwargs['user'] = self.request.user
|
||||
return kwargs
|
||||
class ValidateDeletionView(TemplateView):
|
||||
template_name = 'authentic2/accounts_delete_validation.html'
|
||||
title = _('Confirm account deletion')
|
||||
user = None
|
||||
|
||||
def form_valid(self, form):
|
||||
utils.send_account_deletion_mail(self.request, self.request.user)
|
||||
models.DeletedUser.objects.delete_user(self.request.user)
|
||||
self.request.user.email += '#%d' % random.randint(1, 10000000)
|
||||
self.request.user.email_verified = False
|
||||
self.request.user.save(update_fields=['email', 'email_verified'])
|
||||
logger.info(u'deletion of account %s requested', self.request.user)
|
||||
hooks.call_hooks('event', name='delete-account', user=self.request.user)
|
||||
message_template = loader.get_template('authentic2/account_deletion_message.html')
|
||||
messages.info(self.request, message_template.render(request=self.request))
|
||||
return super(DeleteView, self).form_valid(form)
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
try:
|
||||
deletion_token = signing.loads(kwargs['deletion_token'],
|
||||
max_age=app_settings.A2_DELETION_REQUEST_LIFETIME)
|
||||
user_pk = deletion_token['user_pk']
|
||||
self.user = get_user_model().objects.get(pk=user_pk)
|
||||
# A user account wont be deactived twice
|
||||
if not self.user.is_active:
|
||||
raise ValidationError(
|
||||
_('This account had previously been deactivated and will be deleted soon.'))
|
||||
logger.info('user %s confirmed the deletion of their own account', self.user)
|
||||
except signing.SignatureExpired:
|
||||
error = _('The account deletion request is too old, try again')
|
||||
except signing.BadSignature:
|
||||
error = _('The account deletion request is invalid, try again')
|
||||
except ValueError:
|
||||
error = _('The account deletion request was not on this site, try again')
|
||||
except ValidationError as e:
|
||||
error = e.message
|
||||
except get_user_model().DoesNotExist:
|
||||
error = _('This account has previously been deleted.')
|
||||
else:
|
||||
return super(ValidateDeletionView, self).dispatch(request, *args, **kwargs)
|
||||
messages.error(request, error)
|
||||
return utils.redirect(request, 'auth_homepage')
|
||||
|
||||
registration_completion = valid_token(RegistrationCompletionView.as_view())
|
||||
def post(self, request, *args, **kwargs):
|
||||
if 'cancel' not in request.POST:
|
||||
utils.send_account_deletion_mail(self.request, self.user)
|
||||
models.DeletedUser.objects.delete_user(self.user)
|
||||
self.user.email += '#%d' % random.randint(1, 10000000)
|
||||
self.user.email_verified = False
|
||||
self.user.save(update_fields=['email', 'email_verified'])
|
||||
logger.info(u'deletion of account %s performed', self.user)
|
||||
hooks.call_hooks('event', name='delete-account', user=self.user)
|
||||
if self.user == request.user:
|
||||
# No validation message displayed, as the user will surely
|
||||
# notice their own account deletion...
|
||||
return utils.redirect(request, 'auth_logout')
|
||||
# No real use for cancel_url or next_url here, assuming the link
|
||||
# has been received by email. We instead redirect the user to the
|
||||
# homepage.
|
||||
messages.info(request, _('Deletion performed.'))
|
||||
return utils.redirect(request, 'auth_homepage')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super(ValidateDeletionView, self).get_context_data(**kwargs)
|
||||
ctx['user'] = self.user # Not necessarily the user in request
|
||||
return ctx
|
||||
|
||||
|
||||
class RegistrationCompleteView(TemplateView):
|
||||
|
|
|
@ -15,10 +15,11 @@
|
|||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
# authentic2
|
||||
|
||||
from utils import login
|
||||
from utils import login, logout, get_link_from_mail
|
||||
import pytest
|
||||
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils.html import escape
|
||||
from django.utils.six.moves.urllib.parse import urlparse
|
||||
|
||||
from authentic2.custom_user.models import User
|
||||
|
@ -41,18 +42,100 @@ def test_account_delete(app, simple_user, mailoutbox):
|
|||
assert simple_user.is_active
|
||||
assert not len(mailoutbox)
|
||||
page = login(app, simple_user, path=reverse('delete_account'))
|
||||
page.form.set('password', simple_user.username)
|
||||
page.form.submit(name='submit').follow()
|
||||
assert len(mailoutbox) == 1
|
||||
link = get_link_from_mail(mailoutbox[0])
|
||||
assert 'Validate account deletion request on testserver' == mailoutbox[0].subject
|
||||
assert [simple_user.email] == mailoutbox[0].to
|
||||
page = app.get(link)
|
||||
# FIXME: webtest does not set the Referer header, so the logout page will always ask for
|
||||
# confirmation under tests
|
||||
response = page.form.submit(name='submit').follow()
|
||||
response = page.form.submit(name='delete').follow()
|
||||
response = response.form.submit()
|
||||
assert len(mailoutbox) == 1
|
||||
assert not User.objects.get(pk=simple_user.pk).is_active
|
||||
assert len(mailoutbox) == 2
|
||||
assert 'Account deletion on testserver' == mailoutbox[1].subject
|
||||
assert [simple_user.email] == mailoutbox[0].to
|
||||
assert urlparse(response.location).path == '/'
|
||||
response = response.follow().follow()
|
||||
assert response.request.url.endswith('/login/?next=/')
|
||||
|
||||
|
||||
def test_account_delete_when_logged_out(app, simple_user, mailoutbox):
|
||||
assert simple_user.is_active
|
||||
assert not len(mailoutbox)
|
||||
page = login(app, simple_user, path=reverse('delete_account'))
|
||||
page.form.submit(name='submit').follow()
|
||||
assert len(mailoutbox) == 1
|
||||
link = get_link_from_mail(mailoutbox[0])
|
||||
logout(app)
|
||||
page = app.get(link)
|
||||
assert 'You are about to delete the account of <strong>%s</strong>.' % \
|
||||
escape(simple_user.get_full_name()) in page.text
|
||||
response = page.form.submit(name='delete').follow().follow()
|
||||
assert not User.objects.get(pk=simple_user.pk).is_active
|
||||
assert len(mailoutbox) == 2
|
||||
assert 'Account deletion on testserver' == mailoutbox[1].subject
|
||||
assert [simple_user.email] == mailoutbox[0].to
|
||||
assert "Deletion performed" in response.text
|
||||
|
||||
|
||||
def test_account_delete_by_other_user(app, simple_user, user_ou1, mailoutbox):
|
||||
assert simple_user.is_active
|
||||
assert user_ou1.is_active
|
||||
assert not len(mailoutbox)
|
||||
page = login(app, simple_user, path=reverse('delete_account'))
|
||||
page.form.submit(name='submit').follow()
|
||||
assert len(mailoutbox) == 1
|
||||
link = get_link_from_mail(mailoutbox[0])
|
||||
logout(app)
|
||||
login(app, user_ou1, path=reverse('account_management'))
|
||||
page = app.get(link)
|
||||
assert 'You are about to delete the account of <strong>%s</strong>.' % \
|
||||
escape(simple_user.get_full_name()) in page.text
|
||||
response = page.form.submit(name='delete').follow()
|
||||
assert not User.objects.get(pk=simple_user.pk).is_active
|
||||
assert User.objects.get(pk=user_ou1.pk).is_active
|
||||
assert "Deletion performed" in response.text
|
||||
assert len(mailoutbox) == 2
|
||||
assert 'Account deletion on testserver' == mailoutbox[1].subject
|
||||
assert [simple_user.email] == mailoutbox[0].to
|
||||
|
||||
|
||||
def test_account_delete_fake_token(app, simple_user, mailoutbox):
|
||||
response = app.get(reverse('validate_deletion', kwargs={'deletion_token': 'thisismostlikelynotavalidtoken'})).follow().follow()
|
||||
assert "The account deletion request is invalid, try again" in response.text
|
||||
|
||||
|
||||
def test_account_delete_expired_token(app, simple_user, mailoutbox, freezer):
|
||||
freezer.move_to('2019-08-01')
|
||||
page = login(app, simple_user, path=reverse('delete_account'))
|
||||
page.form.submit(name='submit').follow()
|
||||
freezer.move_to('2019-08-04') # Too late...
|
||||
link = get_link_from_mail(mailoutbox[0])
|
||||
response = app.get(link).follow()
|
||||
assert "The account deletion request is too old, try again" in response.text
|
||||
|
||||
|
||||
def test_account_delete_valid_token_unexistent_user(app, simple_user, mailoutbox):
|
||||
page = login(app, simple_user, path=reverse('delete_account'))
|
||||
page.form.submit(name='submit').follow()
|
||||
link = get_link_from_mail(mailoutbox[0])
|
||||
simple_user.delete()
|
||||
response = app.get(link).follow().follow()
|
||||
assert 'This account has previously been deleted.' in response.text
|
||||
|
||||
|
||||
def test_account_delete_valid_token_inactive_user(app, simple_user, mailoutbox):
|
||||
page = login(app, simple_user, path=reverse('delete_account'))
|
||||
page.form.submit(name='submit').follow()
|
||||
link = get_link_from_mail(mailoutbox[0])
|
||||
simple_user.is_active = False
|
||||
simple_user.save()
|
||||
response = app.get(link).follow()
|
||||
assert "This account had previously been deactivated" in response.text
|
||||
|
||||
|
||||
def test_login_invalid_next(app):
|
||||
app.get(reverse('auth_login') + '?next=plop')
|
||||
|
||||
|
|
Loading…
Reference in New Issue