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_request.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 |
{% trans "Send an account-deletion validation code?" %} |
|
20 |
</p> |
|
21 |
<button class="submit-button" name="submit">{% trans "Send the code" %}</button> |
|
21 | 22 |
<button class="cancel-button" name="cancel" formnovalidate>{% trans "Cancel" %}</button> |
22 | 23 |
</form> |
23 | 24 |
{% endblock %} |
src/authentic2/templates/authentic2/accounts_delete_validation.html | ||
---|---|---|
1 |
{% extends "authentic2/base-page.html" %} |
|
2 |
{% load i18n %} |
|
3 | ||
4 |
{% block page-title %} |
|
5 |
{{ block.super }} - {{ view.title }} |
|
6 |
{% endblock %} |
|
7 | ||
8 |
{% block breadcrumb %} |
|
9 |
{{ block.super }} |
|
10 |
<a href="{% url "account_management" %}">{% trans "Your account" %}</a> |
|
11 |
<a href="#">{{ view.title }}</a> |
|
12 |
{% endblock %} |
|
13 | ||
14 | ||
15 |
{% block content %} |
|
16 |
<form method="post"> |
|
17 |
{% csrf_token %} |
|
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="delete">{% trans "Confirm deletion" %}</button> |
|
25 |
<button class="cancel-button" name="cancel" formnovalidate>{% trans "Cancel" %}</button> |
|
26 |
</form> |
|
27 |
{% 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 | ||
1065 | ||
1066 |
class DeleteView(TemplateView): |
|
1067 |
template_name = 'authentic2/accounts_delete_request.html' |
|
1068 |
title = _('Request account deletion') |
|
1067 | 1069 | |
1068 | 1070 |
def dispatch(self, request, *args, **kwargs): |
1069 | 1071 |
if not app_settings.A2_REGISTRATION_CAN_DELETE_ACCOUNT: |
... | ... | |
1071 | 1073 |
return super(DeleteView, self).dispatch(request, *args, **kwargs) |
1072 | 1074 | |
1073 | 1075 |
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) |
|
1076 |
utils.send_account_deletion_code(self.request, self.request.user) |
|
1077 |
messages.info(request, |
|
1078 |
_("An account deletion validation email has been sent to your email address.")) |
|
1079 |
return utils.redirect(request, 'account_management') |
|
1077 | 1080 | |
1078 |
def get_form_class(self): |
|
1079 |
if self.request.user.has_usable_password(): |
|
1080 |
return profile_forms.DeleteAccountForm |
|
1081 |
return Form |
|
1082 | 1081 | |
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 |
|
1082 |
class ValidateDeletionView(TemplateView): |
|
1083 |
template_name = 'authentic2/accounts_delete_validation.html' |
|
1084 |
title = _('Confirm account deletion') |
|
1085 |
user = None |
|
1088 | 1086 | |
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) |
|
1087 |
def dispatch(self, request, *args, **kwargs): |
|
1088 |
try: |
|
1089 |
deletion_token = signing.loads(kwargs['deletion_token'], |
|
1090 |
max_age=app_settings.A2_DELETION_REQUEST_LIFETIME) |
|
1091 |
user_pk = deletion_token['user_pk'] |
|
1092 |
self.user = get_user_model().objects.get(pk=user_pk) |
|
1093 |
# A user account wont be deactived twice |
|
1094 |
if not self.user.is_active: |
|
1095 |
raise ValidationError( |
|
1096 |
_('This account had previously been deactivated and will be deleted soon.')) |
|
1097 |
logger.info('user %s confirmed the deletion of their own account', self.user) |
|
1098 |
except signing.SignatureExpired: |
|
1099 |
error = _('The account deletion request is too old, try again') |
|
1100 |
except signing.BadSignature: |
|
1101 |
error = _('The account deletion request is invalid, try again') |
|
1102 |
except ValueError: |
|
1103 |
error = _('The account deletion request was not on this site, try again') |
|
1104 |
except ValidationError as e: |
|
1105 |
error = e.message |
|
1106 |
except get_user_model().DoesNotExist: |
|
1107 |
error = _('This account has previously been deleted.') |
|
1108 |
else: |
|
1109 |
return super(ValidateDeletionView, self).dispatch(request, *args, **kwargs) |
|
1110 |
messages.error(request, error) |
|
1111 |
return utils.redirect(request, 'auth_homepage') |
|
1100 | 1112 | |
1101 |
registration_completion = valid_token(RegistrationCompletionView.as_view()) |
|
1113 |
def post(self, request, *args, **kwargs): |
|
1114 |
if 'cancel' not in request.POST: |
|
1115 |
utils.send_account_deletion_mail(self.request, self.user) |
|
1116 |
models.DeletedUser.objects.delete_user(self.user) |
|
1117 |
self.user.email += '#%d' % random.randint(1, 10000000) |
|
1118 |
self.user.email_verified = False |
|
1119 |
self.user.save(update_fields=['email', 'email_verified']) |
|
1120 |
logger.info(u'deletion of account %s performed', self.user) |
|
1121 |
hooks.call_hooks('event', name='delete-account', user=self.user) |
|
1122 |
if self.user == request.user: |
|
1123 |
# No validation message displayed, as the user will surely |
|
1124 |
# notice their own account deletion... |
|
1125 |
return utils.redirect(request, 'auth_logout') |
|
1126 |
# No real use for cancel_url or next_url here, assuming the link |
|
1127 |
# has been received by email. We instead redirect the user to the |
|
1128 |
# homepage. |
|
1129 |
messages.info(request, _('Deletion performed.')) |
|
1130 |
return utils.redirect(request, 'auth_homepage') |
|
1131 | ||
1132 |
def get_context_data(self, **kwargs): |
|
1133 |
ctx = super(ValidateDeletionView, self).get_context_data(**kwargs) |
|
1134 |
ctx['user'] = self.user # Not necessarily the user in request |
|
1135 |
return ctx |
|
1102 | 1136 | |
1103 | 1137 | |
1104 | 1138 |
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 |
page.form.submit(name='submit').follow() |
|
46 |
assert len(mailoutbox) == 1 |
|
47 |
link = get_link_from_mail(mailoutbox[0]) |
|
48 |
assert 'Validate account deletion request on testserver' == mailoutbox[0].subject |
|
49 |
assert [simple_user.email] == mailoutbox[0].to |
|
50 |
page = app.get(link) |
|
45 | 51 |
# FIXME: webtest does not set the Referer header, so the logout page will always ask for |
46 | 52 |
# confirmation under tests |
47 |
response = page.form.submit(name='submit').follow()
|
|
53 |
response = page.form.submit(name='delete').follow()
|
|
48 | 54 |
response = response.form.submit() |
49 |
assert len(mailoutbox) == 1 |
|
50 | 55 |
assert not User.objects.get(pk=simple_user.pk).is_active |
56 |
assert len(mailoutbox) == 2 |
|
57 |
assert 'Account deletion on testserver' == mailoutbox[1].subject |
|
58 |
assert [simple_user.email] == mailoutbox[0].to |
|
51 | 59 |
assert urlparse(response.location).path == '/' |
52 | 60 |
response = response.follow().follow() |
53 | 61 |
assert response.request.url.endswith('/login/?next=/') |
54 | 62 | |
55 | 63 | |
64 |
def test_account_delete_when_logged_out(app, simple_user, mailoutbox): |
|
65 |
assert simple_user.is_active |
|
66 |
assert not len(mailoutbox) |
|
67 |
page = login(app, simple_user, path=reverse('delete_account')) |
|
68 |
page.form.submit(name='submit').follow() |
|
69 |
assert len(mailoutbox) == 1 |
|
70 |
link = get_link_from_mail(mailoutbox[0]) |
|
71 |
logout(app) |
|
72 |
page = app.get(link) |
|
73 |
assert 'You are about to delete the account of <strong>%s</strong>.' % \ |
|
74 |
escape(simple_user.get_full_name()) in page.text |
|
75 |
response = page.form.submit(name='delete').follow().follow() |
|
76 |
assert not User.objects.get(pk=simple_user.pk).is_active |
|
77 |
assert len(mailoutbox) == 2 |
|
78 |
assert 'Account deletion on testserver' == mailoutbox[1].subject |
|
79 |
assert [simple_user.email] == mailoutbox[0].to |
|
80 |
assert "Deletion performed" in response.text |
|
81 | ||
82 | ||
83 |
def test_account_delete_by_other_user(app, simple_user, user_ou1, mailoutbox): |
|
84 |
assert simple_user.is_active |
|
85 |
assert user_ou1.is_active |
|
86 |
assert not len(mailoutbox) |
|
87 |
page = login(app, simple_user, path=reverse('delete_account')) |
|
88 |
page.form.submit(name='submit').follow() |
|
89 |
assert len(mailoutbox) == 1 |
|
90 |
link = get_link_from_mail(mailoutbox[0]) |
|
91 |
logout(app) |
|
92 |
login(app, user_ou1, path=reverse('account_management')) |
|
93 |
page = app.get(link) |
|
94 |
assert 'You are about to delete the account of <strong>%s</strong>.' % \ |
|
95 |
escape(simple_user.get_full_name()) in page.text |
|
96 |
response = page.form.submit(name='delete').follow() |
|
97 |
assert not User.objects.get(pk=simple_user.pk).is_active |
|
98 |
assert User.objects.get(pk=user_ou1.pk).is_active |
|
99 |
assert "Deletion performed" in response.text |
|
100 |
assert len(mailoutbox) == 2 |
|
101 |
assert 'Account deletion on testserver' == mailoutbox[1].subject |
|
102 |
assert [simple_user.email] == mailoutbox[0].to |
|
103 | ||
104 | ||
105 |
def test_account_delete_fake_token(app, simple_user, mailoutbox): |
|
106 |
response = app.get(reverse('validate_deletion', kwargs={'deletion_token': 'thisismostlikelynotavalidtoken'})).follow().follow() |
|
107 |
assert "The account deletion request is invalid, try again" in response.text |
|
108 | ||
109 | ||
110 |
def test_account_delete_expired_token(app, simple_user, mailoutbox, freezer): |
|
111 |
freezer.move_to('2019-08-01') |
|
112 |
page = login(app, simple_user, path=reverse('delete_account')) |
|
113 |
page.form.submit(name='submit').follow() |
|
114 |
freezer.move_to('2019-08-04') # Too late... |
|
115 |
link = get_link_from_mail(mailoutbox[0]) |
|
116 |
response = app.get(link).follow() |
|
117 |
assert "The account deletion request is too old, try again" in response.text |
|
118 | ||
119 | ||
120 |
def test_account_delete_valid_token_unexistent_user(app, simple_user, mailoutbox): |
|
121 |
page = login(app, simple_user, path=reverse('delete_account')) |
|
122 |
page.form.submit(name='submit').follow() |
|
123 |
link = get_link_from_mail(mailoutbox[0]) |
|
124 |
simple_user.delete() |
|
125 |
response = app.get(link).follow().follow() |
|
126 |
assert 'This account has previously been deleted.' in response.text |
|
127 | ||
128 | ||
129 |
def test_account_delete_valid_token_inactive_user(app, simple_user, mailoutbox): |
|
130 |
page = login(app, simple_user, path=reverse('delete_account')) |
|
131 |
page.form.submit(name='submit').follow() |
|
132 |
link = get_link_from_mail(mailoutbox[0]) |
|
133 |
simple_user.is_active = False |
|
134 |
simple_user.save() |
|
135 |
response = app.get(link).follow() |
|
136 |
assert "This account had previously been deactivated" in response.text |
|
137 | ||
138 | ||
56 | 139 |
def test_login_invalid_next(app): |
57 | 140 |
app.get(reverse('auth_login') + '?next=plop') |
58 | 141 | |
59 |
- |