Allow users to temporarily deactivate their accounts (#2324)

This commit is contained in:
Mouse Reeve 2022-11-10 13:40:54 -08:00 committed by GitHub
parent 3ebd957d3d
commit eae1866992
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 333 additions and 39 deletions

View file

@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
import pyotp import pyotp
from bookwyrm import models from bookwyrm import models
from bookwyrm.settings import DOMAIN
from .custom_form import CustomForm from .custom_form import CustomForm
@ -20,6 +21,21 @@ class LoginForm(CustomForm):
"password": forms.PasswordInput(), "password": forms.PasswordInput(),
} }
def infer_username(self):
"""Users may enter their localname, username, or email"""
localname = self.data.get("localname")
if "@" in localname: # looks like an email address to me
try:
return models.User.objects.get(email=localname).username
except models.User.DoesNotExist: # maybe it's a full username?
return localname
return f"{localname}@{DOMAIN}"
def add_invalid_password_error(self):
"""We don't want to be too specific about this"""
# pylint: disable=attribute-defined-outside-init
self.non_field_errors = _("Username or password are incorrect")
class RegisterForm(CustomForm): class RegisterForm(CustomForm):
class Meta: class Meta:

View file

@ -0,0 +1,52 @@
# Generated by Django 3.2.15 on 2022-11-01 22:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0159_auto_20220924_0634"),
]
operations = [
migrations.AddField(
model_name="user",
name="allow_reactivation",
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name="connector",
name="deactivation_reason",
field=models.CharField(
blank=True,
choices=[
("pending", "Pending"),
("self_deletion", "Self deletion"),
("self_deactivation", "Self deactivation"),
("moderator_suspension", "Moderator suspension"),
("moderator_deletion", "Moderator deletion"),
("domain_block", "Domain block"),
],
max_length=255,
null=True,
),
),
migrations.AlterField(
model_name="user",
name="deactivation_reason",
field=models.CharField(
blank=True,
choices=[
("pending", "Pending"),
("self_deletion", "Self deletion"),
("self_deactivation", "Self deactivation"),
("moderator_suspension", "Moderator suspension"),
("moderator_deletion", "Moderator deletion"),
("domain_block", "Domain block"),
],
max_length=255,
null=True,
),
),
]

View file

@ -0,0 +1,13 @@
# Generated by Django 3.2.15 on 2022-11-10 20:34
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0160_auto_20221101_2251"),
("bookwyrm", "0162_importjob_task_id"),
]
operations = []

View file

@ -17,6 +17,7 @@ from .fields import RemoteIdField
DeactivationReason = [ DeactivationReason = [
("pending", _("Pending")), ("pending", _("Pending")),
("self_deletion", _("Self deletion")), ("self_deletion", _("Self deletion")),
("self_deactivation", _("Self deactivation")),
("moderator_suspension", _("Moderator suspension")), ("moderator_suspension", _("Moderator suspension")),
("moderator_deletion", _("Moderator deletion")), ("moderator_deletion", _("Moderator deletion")),
("domain_block", _("Domain block")), ("domain_block", _("Domain block")),

View file

@ -47,6 +47,7 @@ def site_link():
return f"{protocol}://{DOMAIN}" return f"{protocol}://{DOMAIN}"
# pylint: disable=too-many-public-methods
class User(OrderedCollectionPageMixin, AbstractUser): class User(OrderedCollectionPageMixin, AbstractUser):
"""a user who wants to read books""" """a user who wants to read books"""
@ -169,6 +170,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
max_length=255, choices=DeactivationReason, null=True, blank=True max_length=255, choices=DeactivationReason, null=True, blank=True
) )
deactivation_date = models.DateTimeField(null=True, blank=True) deactivation_date = models.DateTimeField(null=True, blank=True)
allow_reactivation = models.BooleanField(default=False)
confirmation_code = models.CharField(max_length=32, default=new_access_code) confirmation_code = models.CharField(max_length=32, default=new_access_code)
name_field = "username" name_field = "username"
@ -367,12 +369,28 @@ class User(OrderedCollectionPageMixin, AbstractUser):
self.create_shelves() self.create_shelves()
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
"""deactivate rather than delete a user""" """We don't actually delete the database entry"""
# pylint: disable=attribute-defined-outside-init # pylint: disable=attribute-defined-outside-init
self.is_active = False self.is_active = False
# skip the logic in this class's save() # skip the logic in this class's save()
super().save(*args, **kwargs) super().save(*args, **kwargs)
def deactivate(self):
"""Disable the user but allow them to reactivate"""
# pylint: disable=attribute-defined-outside-init
self.is_active = False
self.deactivation_reason = "self_deactivation"
self.allow_reactivation = True
super().save(broadcast=False)
def reactivate(self):
"""Now you want to come back, huh?"""
# pylint: disable=attribute-defined-outside-init
self.is_active = True
self.deactivation_reason = None
self.allow_reactivation = False
super().save(broadcast=False)
@property @property
def local_path(self): def local_path(self):
"""this model doesn't inherit bookwyrm model, so here we are""" """this model doesn't inherit bookwyrm model, so here we are"""

View file

@ -21,7 +21,7 @@ RELEASE_API = env(
PAGE_LENGTH = env("PAGE_LENGTH", 15) PAGE_LENGTH = env("PAGE_LENGTH", 15)
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English") DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
JS_CACHE = "e678183b" JS_CACHE = "e678183c"
# email # email
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend") EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")

View file

@ -14,7 +14,7 @@
{% if show_confirmed_email %} {% if show_confirmed_email %}
<p class="notification is-success">{% trans "Success! Email address confirmed." %}</p> <p class="notification is-success">{% trans "Success! Email address confirmed." %}</p>
{% endif %} {% endif %}
<form name="login-confirm" method="post" action="/login"> <form name="login-confirm" method="post" action="{% url 'login' %}">
{% csrf_token %} {% csrf_token %}
{% if show_confirmed_email %}<input type="hidden" name="first_login" value="true">{% endif %} {% if show_confirmed_email %}<input type="hidden" name="first_login" value="true">{% endif %}
<div class="field"> <div class="field">

View file

@ -0,0 +1,60 @@
{% extends 'layout.html' %}
{% load i18n %}
{% block title %}{% trans "Reactivate Account" %}{% endblock %}
{% block content %}
<h1 class="title">{% trans "Reactivate Account" %}</h1>
<div class="columns is-multiline">
<div class="column is-half">
{% if login_form.non_field_errors %}
<p class="notification is-danger">{{ login_form.non_field_errors }}</p>
{% endif %}
<form name="login-confirm" method="post" action="{% url 'prefs-reactivate' %}">
{% csrf_token %}
<div class="field">
<label class="label" for="id_localname_confirm">{% trans "Username:" %}</label>
<div class="control">
<input type="text" name="localname" maxlength="255" class="input" required="" id="id_localname_confirm" value="{{ login_form.localname.value|default:'' }}">
</div>
</div>
<div class="field">
<label class="label" for="id_password_confirm">{% trans "Password:" %}</label>
<div class="control">
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password_confirm" aria-describedby="desc_password">
</div>
{% include 'snippets/form_errors.html' with errors_list=login_form.password.errors id="desc_password" %}
</div>
<div class="field is-grouped">
<div class="control">
<button class="button is-primary" type="submit">{% trans "Reactivate account" %}</button>
</div>
</div>
</form>
</div>
{% if site.allow_registration %}
<div class="column is-half">
<div class="box has-background-primary-light">
<h2 class="title">{% trans "Create an Account" %}</h2>
<form name="register" method="post" action="/register">
{% include 'snippets/register_form.html' %}
</form>
</div>
</div>
{% endif %}
<div class="column">
<div class="box">
{% include 'snippets/about.html' %}
<p class="block">
<a href="{% url 'about' %}">{% trans "More about this site" %}</a>
</p>
</div>
</div>
</div>
{% endblock %}

View file

@ -8,22 +8,37 @@
{% endblock %} {% endblock %}
{% block panel %} {% block panel %}
<div class="block">
<h2 class="title is-4">{% trans "Deactivate account" %}</h2>
<div class="box">
<p class="notification is-link is-light">
{% trans "Your account will be hidden. You can log back in at any time to re-activate your account." %}
</p>
<form name="deactivate-user" action="{% url 'prefs-deactivate' %}" method="post">
{% csrf_token %}
<button type="submit" class="button is-link">{% trans "Deactivate Account" %}</button>
</form>
</div>
</div>
<div class="block"> <div class="block">
<h2 class="title is-4">{% trans "Permanently delete account" %}</h2> <h2 class="title is-4">{% trans "Permanently delete account" %}</h2>
<p class="notification is-danger is-light"> <div class="box">
{% trans "Deleting your account cannot be undone. The username will not be available to register in the future." %} <p class="notification is-danger is-light">
</p> {% trans "Deleting your account cannot be undone. The username will not be available to register in the future." %}
</p>
<form name="delete-user" action="{% url 'prefs-delete' %}" method="post"> <form name="delete-user" action="{% url 'prefs-delete' %}" method="post">
{% csrf_token %} {% csrf_token %}
<div class="field"> <div class="field">
<label class="label" for="id_password">{% trans "Confirm password:" %}</label> <label class="label" for="id_password">{% trans "Confirm password:" %}</label>
<input class="input {% if form.password.errors %}is-danger{% endif %}" type="password" name="password" id="id_password" required aria-describedby="desc_password"> <input class="input {% if form.password.errors %}is-danger{% endif %}" type="password" name="password" id="id_password" required aria-describedby="desc_password">
{% include 'snippets/form_errors.html' with errors_list=form.password.errors id="desc_password" %} {% include 'snippets/form_errors.html' with errors_list=form.password.errors id="desc_password" %}
</div> </div>
<button type="submit" class="button is-danger">{% trans "Delete Account" %}</button> <button type="submit" class="button is-danger">{% trans "Delete Account" %}</button>
</form> </form>
</div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -17,6 +17,7 @@ from bookwyrm.tests.validate_html import validate_html
class LoginViews(TestCase): class LoginViews(TestCase):
"""login and password management""" """login and password management"""
# pylint: disable=invalid-name
def setUp(self): def setUp(self):
"""we need basic test data and mocks""" """we need basic test data and mocks"""
self.factory = RequestFactory() self.factory = RequestFactory()
@ -81,7 +82,7 @@ class LoginViews(TestCase):
self.assertEqual(result.status_code, 302) self.assertEqual(result.status_code, 302)
def test_login_post_username(self, *_): def test_login_post_username(self, *_):
"""there are so many views, this just makes sure it LOADS""" """valid login where the user provides their user@domain.com username"""
view = views.Login.as_view() view = views.Login.as_view()
form = forms.LoginForm() form = forms.LoginForm()
form.data["localname"] = "mouse@your.domain.here" form.data["localname"] = "mouse@your.domain.here"

View file

@ -2,6 +2,7 @@
import json import json
from unittest.mock import patch from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser
from django.contrib.sessions.middleware import SessionMiddleware from django.contrib.sessions.middleware import SessionMiddleware
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.test import TestCase from django.test import TestCase
@ -15,6 +16,7 @@ from bookwyrm.tests.validate_html import validate_html
class DeleteUserViews(TestCase): class DeleteUserViews(TestCase):
"""view user and edit profile""" """view user and edit profile"""
# pylint: disable=invalid-name
def setUp(self): def setUp(self):
"""we need basic test data and mocks""" """we need basic test data and mocks"""
self.factory = RequestFactory() self.factory = RequestFactory()
@ -22,14 +24,18 @@ class DeleteUserViews(TestCase):
"bookwyrm.activitystreams.populate_stream_task.delay" "bookwyrm.activitystreams.populate_stream_task.delay"
), patch("bookwyrm.lists_stream.populate_lists_task.delay"): ), patch("bookwyrm.lists_stream.populate_lists_task.delay"):
self.local_user = models.User.objects.create_user( self.local_user = models.User.objects.create_user(
"mouse@local.com", "mouse@your.domain.here",
"mouse@mouse.mouse", "mouse@mouse.mouse",
"password", "password",
local=True, local=True,
localname="mouse", localname="mouse",
) )
self.rat = models.User.objects.create_user( self.rat = models.User.objects.create_user(
"rat@local.com", "rat@rat.rat", "password", local=True, localname="rat" "rat@your.domain.here",
"rat@rat.rat",
"password",
local=True,
localname="rat",
) )
self.book = models.Edition.objects.create( self.book = models.Edition.objects.create(
@ -44,6 +50,8 @@ class DeleteUserViews(TestCase):
shelf=self.local_user.shelf_set.first(), shelf=self.local_user.shelf_set.first(),
) )
self.anonymous_user = AnonymousUser
self.anonymous_user.is_authenticated = False
models.SiteSettings.objects.create() models.SiteSettings.objects.create()
def test_delete_user_page(self, _): def test_delete_user_page(self, _):
@ -84,3 +92,52 @@ class DeleteUserViews(TestCase):
self.local_user.refresh_from_db() self.local_user.refresh_from_db()
self.assertFalse(self.local_user.is_active) self.assertFalse(self.local_user.is_active)
self.assertEqual(self.local_user.deactivation_reason, "self_deletion") self.assertEqual(self.local_user.deactivation_reason, "self_deletion")
def test_deactivate_user(self, _):
"""Impermanent deletion"""
self.assertTrue(self.local_user.is_active)
view = views.DeactivateUser.as_view()
request = self.factory.post("")
request.user = self.local_user
middleware = SessionMiddleware()
middleware.process_request(request)
request.session.save()
view(request)
self.local_user.refresh_from_db()
self.assertFalse(self.local_user.is_active)
self.assertEqual(self.local_user.deactivation_reason, "self_deactivation")
def test_reactivate_user_get(self, _):
"""Reactication page"""
view = views.ReactivateUser.as_view()
request = self.factory.get("")
request.user = self.anonymous_user
result = view(request)
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_reactivate_user_post(self, _):
"""Reactivate action"""
self.local_user.deactivate()
self.local_user.refresh_from_db()
view = views.ReactivateUser.as_view()
form = forms.LoginForm()
form.data["localname"] = "mouse"
form.data["password"] = "password"
request = self.factory.post("", form.data)
request.user = self.local_user
middleware = SessionMiddleware()
middleware.process_request(request)
request.session.save()
with patch("bookwyrm.views.preferences.delete_user.login"):
view(request)
self.local_user.refresh_from_db()
self.assertTrue(self.local_user.is_active)
self.assertIsNone(self.local_user.deactivation_reason)

View file

@ -526,6 +526,16 @@ urlpatterns = [
), ),
re_path(r"^preferences/export/?$", views.Export.as_view(), name="prefs-export"), re_path(r"^preferences/export/?$", views.Export.as_view(), name="prefs-export"),
re_path(r"^preferences/delete/?$", views.DeleteUser.as_view(), name="prefs-delete"), re_path(r"^preferences/delete/?$", views.DeleteUser.as_view(), name="prefs-delete"),
re_path(
r"^preferences/deactivate/?$",
views.DeactivateUser.as_view(),
name="prefs-deactivate",
),
re_path(
r"^preferences/reactivate/?$",
views.ReactivateUser.as_view(),
name="prefs-reactivate",
),
re_path(r"^preferences/block/?$", views.Block.as_view(), name="prefs-block"), re_path(r"^preferences/block/?$", views.Block.as_view(), name="prefs-block"),
re_path(r"^block/(?P<user_id>\d+)/?$", views.Block.as_view()), re_path(r"^block/(?P<user_id>\d+)/?$", views.Block.as_view()),
re_path(r"^unblock/(?P<user_id>\d+)/?$", views.unblock), re_path(r"^unblock/(?P<user_id>\d+)/?$", views.unblock),

View file

@ -31,7 +31,7 @@ from .admin.user_admin import UserAdmin, UserAdminList
from .preferences.change_password import ChangePassword from .preferences.change_password import ChangePassword
from .preferences.edit_user import EditUser from .preferences.edit_user import EditUser
from .preferences.export import Export from .preferences.export import Export
from .preferences.delete_user import DeleteUser from .preferences.delete_user import DeleteUser, DeactivateUser, ReactivateUser
from .preferences.block import Block, unblock from .preferences.block import Block, unblock
from .preferences.two_factor_auth import ( from .preferences.two_factor_auth import (
Edit2FA, Edit2FA,

View file

@ -6,12 +6,10 @@ from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect from django.shortcuts import redirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _
from django.views import View from django.views import View
from django.views.decorators.debug import sensitive_variables, sensitive_post_parameters from django.views.decorators.debug import sensitive_variables, sensitive_post_parameters
from bookwyrm import forms, models from bookwyrm import forms, models
from bookwyrm.settings import DOMAIN
from bookwyrm.views.helpers import set_language from bookwyrm.views.helpers import set_language
@ -40,19 +38,13 @@ class Login(View):
return redirect("/") return redirect("/")
login_form = forms.LoginForm(request.POST) login_form = forms.LoginForm(request.POST)
localname = login_form.data.get("localname") # who do we think is trying to log in
username = login_form.infer_username()
if "@" in localname: # looks like an email address to me
try:
username = models.User.objects.get(email=localname).username
except models.User.DoesNotExist: # maybe it's a full username?
username = localname
else:
username = f"{localname}@{DOMAIN}"
password = login_form.data.get("password") password = login_form.data.get("password")
# perform authentication # perform authentication
user = authenticate(request, username=username, password=password) user = authenticate(request, username=username, password=password)
if user is not None: if user is not None:
# if 2fa is set, don't log them in until they enter the right code # if 2fa is set, don't log them in until they enter the right code
if user.two_factor_auth: if user.two_factor_auth:
@ -76,14 +68,22 @@ class Login(View):
return set_language(user, redirect("/")) return set_language(user, redirect("/"))
# maybe the user is pending email confirmation user_attempt = models.User.objects.filter(
if models.User.objects.filter( username=username, is_active=False
username=username, is_active=False, deactivation_reason="pending" ).first()
).exists(): if user_attempt and user_attempt.deactivation_reason == "pending":
# maybe the user is pending email confirmation
return redirect("confirm-email") return redirect("confirm-email")
if (
user_attempt
and user_attempt.allow_reactivation
and user_attempt.check_password(password)
):
# maybe we want to reactivate an account?
return redirect("prefs-reactivate")
# login errors # login errors
login_form.non_field_errors = _("Username or password are incorrect") login_form.add_invalid_password_error()
register_form = forms.RegisterForm() register_form = forms.RegisterForm()
data = {"login_form": login_form, "register_form": register_form} data = {"login_form": login_form, "register_form": register_form}
return TemplateResponse(request, "landing/login.html", data) return TemplateResponse(request, "landing/login.html", data)

View file

@ -1,7 +1,9 @@
""" edit your own account """ """ edit your own account """
from django.contrib.auth import logout import time
from django.contrib.auth import login, logout
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views import View from django.views import View
@ -23,7 +25,7 @@ class DeleteUser(View):
return TemplateResponse(request, "preferences/delete_user.html", data) return TemplateResponse(request, "preferences/delete_user.html", data)
def post(self, request): def post(self, request):
"""les get fancy with images""" """There's no going back from this"""
form = forms.DeleteUserForm(request.POST, instance=request.user) form = forms.DeleteUserForm(request.POST, instance=request.user)
# idk why but I couldn't get check_password to work on request.user # idk why but I couldn't get check_password to work on request.user
user = models.User.objects.get(id=request.user.id) user = models.User.objects.get(id=request.user.id)
@ -36,3 +38,49 @@ class DeleteUser(View):
form.errors["password"] = ["Invalid password"] form.errors["password"] = ["Invalid password"]
data = {"form": form, "user": request.user} data = {"form": form, "user": request.user}
return TemplateResponse(request, "preferences/delete_user.html", data) return TemplateResponse(request, "preferences/delete_user.html", data)
@method_decorator(login_required, name="dispatch")
class DeactivateUser(View):
"""deactivate user view"""
def post(self, request):
"""You can reactivate"""
request.user.deactivate()
logout(request)
return redirect("/")
class ReactivateUser(View):
"""now reactivate the user"""
def get(self, request):
"""so you want to rejoin?"""
if request.user.is_authenticated:
return redirect("/")
data = {"login_form": forms.LoginForm()}
return TemplateResponse(request, "landing/reactivate.html", data)
def post(self, request):
"""reactivate that baby"""
login_form = forms.LoginForm(request.POST)
username = login_form.infer_username()
password = login_form.data.get("password")
user = get_object_or_404(models.User, username=username)
# we can't use "authenticate" because that requires an active user
if not user.check_password(password):
login_form.add_invalid_password_error()
data = {"login_form": login_form}
return TemplateResponse(request, "landing/reactivate.html", data)
# Correct password, do you need 2fa too?
if user.two_factor_auth:
request.session["2fa_user"] = user.username
request.session["2fa_auth_time"] = time.time()
return redirect("login-with-2fa")
user.reactivate()
login(request, user)
return redirect("/")

View file

@ -133,6 +133,9 @@ class LoginWith2FA(View):
request, "two_factor_auth/two_factor_login.html", data request, "two_factor_auth/two_factor_login.html", data
) )
# is this a reactivate? let's go for it
if not user.is_active and user.allow_reactivation:
user.reactivate()
# log the user in - we are bypassing standard login # log the user in - we are bypassing standard login
login(request, user) login(request, user)
user.update_active_date() user.update_active_date()