0001-accounts-send-validation-email-before-self-triggered.patch
src/authentic2/app_settings.py | ||
---|---|---|
278 | 278 |
A2_EMAIL_CHANGE_TOKEN_LIFETIME=Setting( |
279 | 279 |
default=7200, |
280 | 280 |
definition='Lifetime in seconds of the token sent to verify email adresses'), |
281 |
A2_DELETION_REQUEST_LIFETIME=Setting( |
|
282 |
default=48*3600, |
|
283 |
definition='Lifetime in seconds of the user account deletion request'), |
|
281 | 284 |
A2_REDIRECT_WHITELIST=Setting( |
282 | 285 |
default=(), |
283 | 286 |
definition='List of origins which are authorized to ask for redirection.'), |
src/authentic2/templates/authentic2/account_delete_notification_subject.txt | ||
---|---|---|
1 |
{% load i18n %}{% autoescape off %}{% blocktrans %}Account deletion request on {{ site }}{% endblocktrans %}{% endautoescape %} |
|
1 |
{% load i18n %}{% autoescape off %}{% blocktrans %}Account deletion on {{ site }}{% endblocktrans %}{% endautoescape %} |
src/authentic2/templates/authentic2/account_deletion_code_body.html | ||
---|---|---|
1 |
{% load i18n %} |
|
2 |
<html> |
|
3 |
<body style="max-width: 90ex"> |
|
4 |
<p>{% blocktrans %}{{ full_name }},{% endblocktrans %}</p> |
|
5 |
<p> |
|
6 |
{% blocktrans %} |
|
7 |
Please click on {{ deletion_url }} |
|
8 |
if you want to validate your account deletion request on |
|
9 |
{{ site }}. |
|
10 |
If so, all related data will be deleted in the next few hours. |
|
11 |
You won't be able to log in with this account anymore. |
|
12 |
{% endblocktrans %} |
|
13 |
</p> |
|
14 |
</body> |
|
15 |
</html> |
src/authentic2/templates/authentic2/account_deletion_code_body.txt | ||
---|---|---|
1 |
{% load i18n %}{% autoescape off %}{% blocktrans %}{{ full_name }},{% endblocktrans %} |
|
2 | ||
3 |
{% blocktrans %} |
|
4 |
Please click on {{ deletion_url }} |
|
5 |
if you want to validate your account deletion request on |
|
6 |
{{ site }}. |
|
7 |
If so, all related data will be deleted in the next few hours. |
|
8 |
You won't be able to log in with this account anymore. |
|
9 |
{% endblocktrans %} |
|
10 |
{% endautoescape %} |
src/authentic2/templates/authentic2/account_deletion_code_subject.txt | ||
---|---|---|
1 |
{% load i18n %}{% autoescape off %}{% blocktrans %}Validate account deletion request on {{ site }}{% endblocktrans %}{% endautoescape %} |
src/authentic2/templates/authentic2/accounts_delete.html → src/authentic2/templates/authentic2/accounts_delete_validation.html | ||
---|---|---|
15 | 15 |
{% block content %} |
16 | 16 |
<form method="post"> |
17 | 17 |
{% csrf_token %} |
18 |
<p>{% trans "Delete my account and all my personal datas ?" %}</p> |
|
19 |
{{ form.as_p }} |
|
20 |
<button class="delete-button" name="submit">{% trans "Delete" %}</button> |
|
18 |
<p> |
|
19 |
{% blocktrans with full_name=user.get_full_name %} |
|
20 |
You are about to delete the account of <strong>{{ full_name }}</strong>. |
|
21 |
This will remove all related personal data and you won't be able to log in with this account anymore. |
|
22 |
{% endblocktrans %} |
|
23 |
</p> |
|
24 |
<button class="delete-button" name="submit">{% trans "Confirm deletion" %}</button> |
|
21 | 25 |
<button class="cancel-button" name="cancel" formnovalidate>{% trans "Cancel" %}</button> |
22 | 26 |
</form> |
23 | 27 |
{% endblock %} |
src/authentic2/templates/authentic2/accounts_plain_message.html | ||
---|---|---|
1 |
{% extends "authentic2/base-page.html" %} |
|
2 |
{% load i18n %} |
|
3 | ||
4 |
{% block page-title %} |
|
5 |
{{ block.super }} - {% trans "Account deletion" %} |
|
6 |
{% endblock %} |
|
7 | ||
8 |
{% block content %} |
|
9 |
<p>{{ message }}</p> |
|
10 |
{% endblock %} |
src/authentic2/urls.py | ||
---|---|---|
44 | 44 |
url(r'^delete/$', |
45 | 45 |
login_required(views.DeleteView.as_view()), |
46 | 46 |
name='delete_account'), |
47 |
url(r'validate-deletion/(?P<deletion_token>[\w: -]+)/$', |
|
48 |
views.ValidateDeletionView.as_view(), |
|
49 |
name='validate_deletion'), |
|
47 | 50 |
url(r'^logged-in/$', |
48 | 51 |
views.logged_in, |
49 | 52 |
name='logged-in'), |
src/authentic2/utils/__init__.py | ||
---|---|---|
682 | 682 |
return activate_url |
683 | 683 | |
684 | 684 | |
685 |
def build_deletion_url(request, **kwargs): |
|
686 |
data = kwargs.copy() |
|
687 |
data['user_pk'] = request.user.pk |
|
688 |
deletion_token = signing.dumps(data) |
|
689 |
delete_url = request.build_absolute_uri( |
|
690 |
reverse('validate_deletion', kwargs={'deletion_token': deletion_token})) |
|
691 |
return delete_url |
|
692 | ||
693 | ||
685 | 694 |
def send_registration_mail(request, email, ou, template_names=None, next_url=None, context=None, |
686 | 695 |
**kwargs): |
687 | 696 |
'''Send a registration mail to an user. All given kwargs will be used |
... | ... | |
728 | 737 |
registration_url) |
729 | 738 | |
730 | 739 | |
740 |
def send_account_deletion_code(request, user): |
|
741 |
'''Send an account deletion notification code to a user. |
|
742 | ||
743 |
Can raise an smtplib.SMTPException |
|
744 |
''' |
|
745 |
logger = logging.getLogger(__name__) |
|
746 |
deletion_url = build_deletion_url(request) |
|
747 |
context = { |
|
748 |
'full_name': request.user.get_full_name(), |
|
749 |
'user': request.user, |
|
750 |
'site': request.get_host(), |
|
751 |
'deletion_url': deletion_url} |
|
752 |
template_names = [ |
|
753 |
'authentic2/account_deletion_code'] |
|
754 |
if user.ou: |
|
755 |
template_names.insert(0, 'authentic2/account_deletion_code_%s' % user.ou.slug) |
|
756 |
send_templated_mail(user.email, template_names, context, request=request) |
|
757 |
logger.info(u'account deletion code sent to %s', user.email) |
|
758 | ||
759 | ||
731 | 760 |
def send_account_deletion_mail(request, user): |
732 | 761 |
'''Send an account deletion notification mail to a user. |
733 | 762 |
src/authentic2/views.py | ||
---|---|---|
1060 | 1060 |
request=self.request) |
1061 | 1061 | |
1062 | 1062 | |
1063 |
class DeleteView(FormView): |
|
1064 |
template_name = 'authentic2/accounts_delete.html' |
|
1065 |
success_url = reverse_lazy('auth_logout') |
|
1066 |
title = _('Delete account') |
|
1063 |
registration_completion = valid_token(RegistrationCompletionView.as_view()) |
|
1064 | ||
1067 | 1065 | |
1066 |
class DeleteView(View): |
|
1068 | 1067 |
def dispatch(self, request, *args, **kwargs): |
1069 | 1068 |
if not app_settings.A2_REGISTRATION_CAN_DELETE_ACCOUNT: |
1070 | 1069 |
return utils.redirect(request, '..') |
1071 | 1070 |
return super(DeleteView, self).dispatch(request, *args, **kwargs) |
1072 | 1071 | |
1073 |
def post(self, request, *args, **kwargs): |
|
1074 |
if 'cancel' in request.POST: |
|
1075 |
return utils.redirect(request, 'account_management') |
|
1076 |
return super(DeleteView, self).post(request, *args, **kwargs) |
|
1072 |
def get(self, request, *args, **kwargs): |
|
1073 |
utils.send_account_deletion_code(self.request, self.request.user) |
|
1074 |
messages.info(request, |
|
1075 |
_("An account deletion validation email has been sent to your email address.")) |
|
1076 |
return utils.redirect(request, 'account_management') |
|
1077 | 1077 | |
1078 |
def get_form_class(self): |
|
1079 |
if self.request.user.has_usable_password(): |
|
1080 |
return profile_forms.DeleteAccountForm |
|
1081 |
return Form |
|
1082 | 1078 | |
1083 |
def get_form_kwargs(self, **kwargs): |
|
1084 |
kwargs = super(DeleteView, self).get_form_kwargs(**kwargs) |
|
1085 |
if self.request.user.has_usable_password(): |
|
1086 |
kwargs['user'] = self.request.user |
|
1087 |
return kwargs |
|
1079 |
class ValidateDeletionView(TemplateView): |
|
1080 |
template_name = 'authentic2/accounts_delete_validation.html' |
|
1081 |
title = _('Confirm account deletion') |
|
1082 |
user = None |
|
1088 | 1083 | |
1089 |
def form_valid(self, form): |
|
1090 |
utils.send_account_deletion_mail(self.request, self.request.user) |
|
1091 |
models.DeletedUser.objects.delete_user(self.request.user) |
|
1092 |
self.request.user.email += '#%d' % random.randint(1, 10000000) |
|
1093 |
self.request.user.email_verified = False |
|
1094 |
self.request.user.save(update_fields=['email', 'email_verified']) |
|
1095 |
logger.info(u'deletion of account %s requested', self.request.user) |
|
1096 |
hooks.call_hooks('event', name='delete-account', user=self.request.user) |
|
1097 |
message_template = loader.get_template('authentic2/account_deletion_message.html') |
|
1098 |
messages.info(self.request, message_template.render(request=self.request)) |
|
1099 |
return super(DeleteView, self).form_valid(form) |
|
1084 |
def dispatch(self, request, *args, **kwargs): |
|
1085 |
error = None |
|
1086 |
try: |
|
1087 |
deletion_token = signing.loads(kwargs['deletion_token'], |
|
1088 |
max_age=app_settings.A2_DELETION_REQUEST_LIFETIME) |
|
1089 |
user_pk = deletion_token['user_pk'] |
|
1090 |
self.user = get_user_model().objects.get(pk=user_pk) |
|
1091 |
# A user account wont be deactived twice |
|
1092 |
if not self.user.is_active: |
|
1093 |
raise ValidationError( |
|
1094 |
_('This account had previously been deactivated and will be deleted soon.')) |
|
1095 |
logger.info('user %s confirmed the deletion of their own account', self.user) |
|
1096 |
except signing.SignatureExpired: |
|
1097 |
error = _('The account deletion request is too old, try again') |
|
1098 |
except signing.BadSignature: |
|
1099 |
error = _('The account deletion request is invalid, try again') |
|
1100 |
except ValueError: |
|
1101 |
error = _('The account deletion request was not on this site, try again') |
|
1102 |
except ValidationError as e: |
|
1103 |
error = e.message |
|
1104 |
except get_user_model().DoesNotExist: |
|
1105 |
error = _('This account has previously been deleted.') |
|
1106 | ||
1107 |
if error: |
|
1108 |
return render(request, 'authentic2/accounts_plain_message.html', |
|
1109 |
context={'message': error}) |
|
1110 |
return super(ValidateDeletionView, self).dispatch(request, *args, **kwargs) |
|
1100 | 1111 | |
1101 |
registration_completion = valid_token(RegistrationCompletionView.as_view()) |
|
1112 |
def post(self, request, *args, **kwargs): |
|
1113 |
if 'cancel' not in request.POST: |
|
1114 |
utils.send_account_deletion_mail(self.request, self.user) |
|
1115 |
models.DeletedUser.objects.delete_user(self.user) |
|
1116 |
self.user.email += '#%d' % random.randint(1, 10000000) |
|
1117 |
self.user.email_verified = False |
|
1118 |
self.user.save(update_fields=['email', 'email_verified']) |
|
1119 |
logger.info(u'deletion of account %s performed', self.user) |
|
1120 |
hooks.call_hooks('event', name='delete-account', user=self.user) |
|
1121 |
if self.user == request.user: |
|
1122 |
# No validation message displayed, as the user will surely |
|
1123 |
# notice their own account deletion... |
|
1124 |
return utils.redirect(request, 'auth_logout') |
|
1125 |
# No real use for cancel_url or next_url here, assuming the link |
|
1126 |
# has been received by email. |
|
1127 |
return render(request, 'authentic2/accounts_plain_message.html', |
|
1128 |
context={'message': 'Deletion performed.'}) |
|
1129 |
return utils.redirect(request, '/') |
|
1130 | ||
1131 |
def get_context_data(self, **kwargs): |
|
1132 |
ctx = super(ValidateDeletionView, self).get_context_data(**kwargs) |
|
1133 |
ctx['user'] = self.user # Not necessarily the user in request |
|
1134 |
return ctx |
|
1102 | 1135 | |
1103 | 1136 | |
1104 | 1137 |
class RegistrationCompleteView(TemplateView): |
tests/test_views.py | ||
---|---|---|
15 | 15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
16 | 16 |
# authentic2 |
17 | 17 | |
18 |
from utils import login |
|
18 |
from utils import login, logout, get_link_from_mail
|
|
19 | 19 |
import pytest |
20 | 20 | |
21 | 21 |
from django.core.urlresolvers import reverse |
22 |
from django.utils.html import escape |
|
22 | 23 |
from django.utils.six.moves.urllib.parse import urlparse |
23 | 24 | |
24 | 25 |
from authentic2.custom_user.models import User |
... | ... | |
41 | 42 |
assert simple_user.is_active |
42 | 43 |
assert not len(mailoutbox) |
43 | 44 |
page = login(app, simple_user, path=reverse('delete_account')) |
44 |
page.form.set('password', simple_user.username) |
|
45 |
assert len(mailoutbox) == 1 |
|
46 |
link = get_link_from_mail(mailoutbox[0]) |
|
47 |
assert 'Validate account deletion request on testserver' == mailoutbox[0].subject |
|
48 |
assert [simple_user.email] == mailoutbox[0].to |
|
49 |
page = app.get(link) |
|
45 | 50 |
# FIXME: webtest does not set the Referer header, so the logout page will always ask for |
46 | 51 |
# confirmation under tests |
47 | 52 |
response = page.form.submit(name='submit').follow() |
48 | 53 |
response = response.form.submit() |
49 |
assert len(mailoutbox) == 1 |
|
50 | 54 |
assert not User.objects.get(pk=simple_user.pk).is_active |
55 |
assert len(mailoutbox) == 2 |
|
56 |
assert 'Account deletion on testserver' == mailoutbox[1].subject |
|
57 |
assert [simple_user.email] == mailoutbox[0].to |
|
51 | 58 |
assert urlparse(response.location).path == '/' |
52 | 59 |
response = response.follow().follow() |
53 | 60 |
assert response.request.url.endswith('/login/?next=/') |
54 | 61 | |
55 | 62 | |
63 |
def test_account_delete_when_logged_out(app, simple_user, mailoutbox): |
|
64 |
assert simple_user.is_active |
|
65 |
assert not len(mailoutbox) |
|
66 |
page = login(app, simple_user, path=reverse('delete_account')) |
|
67 |
assert len(mailoutbox) == 1 |
|
68 |
link = get_link_from_mail(mailoutbox[0]) |
|
69 |
logout(app) |
|
70 |
page = app.get(link) |
|
71 |
assert 'You are about to delete the account of <strong>%s</strong>.' % \ |
|
72 |
escape(simple_user.get_full_name()) in page.text |
|
73 |
response = page.form.submit(name='submit') |
|
74 |
assert not User.objects.get(pk=simple_user.pk).is_active |
|
75 |
assert len(mailoutbox) == 2 |
|
76 |
assert 'Account deletion on testserver' == mailoutbox[1].subject |
|
77 |
assert [simple_user.email] == mailoutbox[0].to |
|
78 |
assert "Deletion performed" in response.text |
|
79 | ||
80 | ||
81 |
def test_account_delete_by_other_user(app, simple_user, user_ou1, mailoutbox): |
|
82 |
assert simple_user.is_active |
|
83 |
assert user_ou1.is_active |
|
84 |
assert not len(mailoutbox) |
|
85 |
page = login(app, simple_user, path=reverse('delete_account')) |
|
86 |
assert len(mailoutbox) == 1 |
|
87 |
link = get_link_from_mail(mailoutbox[0]) |
|
88 |
logout(app) |
|
89 |
login(app, user_ou1, path=reverse('account_management')) |
|
90 |
page = app.get(link) |
|
91 |
assert 'You are about to delete the account of <strong>%s</strong>.' % \ |
|
92 |
escape(simple_user.get_full_name()) in page.text |
|
93 |
response = page.form.submit(name='submit') |
|
94 |
assert not User.objects.get(pk=simple_user.pk).is_active |
|
95 |
assert User.objects.get(pk=user_ou1.pk).is_active |
|
96 |
assert "Deletion performed" in response.text |
|
97 |
assert len(mailoutbox) == 2 |
|
98 |
assert 'Account deletion on testserver' == mailoutbox[1].subject |
|
99 |
assert [simple_user.email] == mailoutbox[0].to |
|
100 | ||
101 | ||
102 |
def test_account_delete_fake_token(app, simple_user, mailoutbox): |
|
103 |
response = app.get(reverse('validate_deletion', kwargs={'deletion_token': 'thisismostlikelynotavalidtoken'})) |
|
104 |
assert "The account deletion request is invalid, try again" in response.text |
|
105 | ||
106 | ||
107 |
def test_account_delete_expired_token(app, simple_user, mailoutbox, freezer): |
|
108 |
freezer.move_to('2019-08-01') |
|
109 |
page = login(app, simple_user, path=reverse('delete_account')) |
|
110 |
freezer.move_to('2019-08-04') # Too late... |
|
111 |
link = get_link_from_mail(mailoutbox[0]) |
|
112 |
response = app.get(link) |
|
113 |
assert "The account deletion request is too old, try again" in response.text |
|
114 | ||
115 | ||
116 |
def test_account_delete_valid_token_unexistent_user(app, simple_user, mailoutbox): |
|
117 |
page = login(app, simple_user, path=reverse('delete_account')) |
|
118 |
link = get_link_from_mail(mailoutbox[0]) |
|
119 |
simple_user.delete() |
|
120 |
response = app.get(link) |
|
121 |
assert 'This account has previously been deleted.' in response.text |
|
122 | ||
123 | ||
124 |
def test_account_delete_valid_token_inactive_user(app, simple_user, mailoutbox): |
|
125 |
page = login(app, simple_user, path=reverse('delete_account')) |
|
126 |
link = get_link_from_mail(mailoutbox[0]) |
|
127 |
simple_user.is_active = False |
|
128 |
simple_user.save() |
|
129 |
response = app.get(link) |
|
130 |
assert "This account had previously been deactivated" in response.text |
|
131 | ||
132 | ||
56 | 133 |
def test_login_invalid_next(app): |
57 | 134 |
app.get(reverse('auth_login') + '?next=plop') |
58 | 135 | |
59 |
- |