From d4b18678bd90340338d3e886295bdaf0a32a6270 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Fri, 2 Oct 2020 13:32:19 -0700 Subject: [PATCH] Forgot password flow --- bookwyrm/migrations/0043_siteinvite.py | 2 +- bookwyrm/migrations/0049_passwordreset.py | 25 ++++++++++ bookwyrm/models/__init__.py | 2 +- bookwyrm/models/site.py | 27 ++++++++++- bookwyrm/settings.py | 7 --- bookwyrm/templates/password_reset.html | 43 +++++++++++++++++ .../templates/password_reset_request.html | 29 ++++++++++++ bookwyrm/urls.py | 5 ++ bookwyrm/view_actions.py | 46 +++++++++++++++++++ bookwyrm/views.py | 23 ++++++++++ celerywyrm/celery.py | 7 +-- celerywyrm/settings.py | 8 ++++ 12 files changed, 210 insertions(+), 14 deletions(-) create mode 100644 bookwyrm/migrations/0049_passwordreset.py create mode 100644 bookwyrm/templates/password_reset.html create mode 100644 bookwyrm/templates/password_reset_request.html diff --git a/bookwyrm/migrations/0043_siteinvite.py b/bookwyrm/migrations/0043_siteinvite.py index ef9f4eabf..d58650632 100644 --- a/bookwyrm/migrations/0043_siteinvite.py +++ b/bookwyrm/migrations/0043_siteinvite.py @@ -15,7 +15,7 @@ class Migration(migrations.Migration): name='SiteInvite', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('code', models.CharField(default=bookwyrm.models.site.new_invite_code, max_length=32)), + ('code', models.CharField(default=bookwyrm.models.site.new_access_code, max_length=32)), ('expiry', models.DateTimeField(blank=True, null=True)), ('use_limit', models.IntegerField(blank=True, null=True)), ('times_used', models.IntegerField(default=0)), diff --git a/bookwyrm/migrations/0049_passwordreset.py b/bookwyrm/migrations/0049_passwordreset.py new file mode 100644 index 000000000..a9e784ad2 --- /dev/null +++ b/bookwyrm/migrations/0049_passwordreset.py @@ -0,0 +1,25 @@ +# Generated by Django 3.0.7 on 2020-10-02 19:43 + +import bookwyrm.models.site +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0048_generatednote'), + ] + + operations = [ + migrations.CreateModel( + name='PasswordReset', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(default=bookwyrm.models.site.new_access_code, max_length=32)), + ('expiry', models.DateTimeField(default=bookwyrm.models.site.get_passowrd_reset_expiry)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/bookwyrm/models/__init__.py b/bookwyrm/models/__init__.py index 1ed4a6737..47ae177bb 100644 --- a/bookwyrm/models/__init__.py +++ b/bookwyrm/models/__init__.py @@ -13,7 +13,7 @@ from .user import User from .federated_server import FederatedServer from .import_job import ImportJob, ImportItem -from .site import SiteSettings, SiteInvite +from .site import SiteSettings, SiteInvite, PasswordReset cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass) activity_models = {c[0]: c[1].activity_serializer for c in cls_members \ diff --git a/bookwyrm/models/site.py b/bookwyrm/models/site.py index 1472ab256..fbf789a03 100644 --- a/bookwyrm/models/site.py +++ b/bookwyrm/models/site.py @@ -1,5 +1,6 @@ ''' the particulars for this instance of BookWyrm ''' import base64 +import datetime from Crypto import Random from django.db import models @@ -27,13 +28,13 @@ class SiteSettings(models.Model): default_settings.save() return default_settings -def new_invite_code(): +def new_access_code(): ''' the identifier for a user invite ''' return base64.b32encode(Random.get_random_bytes(5)).decode('ascii') class SiteInvite(models.Model): ''' gives someone access to create an account on the instance ''' - code = models.CharField(max_length=32, default=new_invite_code) + code = models.CharField(max_length=32, default=new_access_code) expiry = models.DateTimeField(blank=True, null=True) use_limit = models.IntegerField(blank=True, null=True) times_used = models.IntegerField(default=0) @@ -49,3 +50,25 @@ class SiteInvite(models.Model): def link(self): ''' formats the invite link ''' return "https://{}/invite/{}".format(DOMAIN, self.code) + + +def get_passowrd_reset_expiry(): + ''' give people a limited time to use the link ''' + now = datetime.datetime.now() + return now + datetime.timedelta(days=1) + + +class PasswordReset(models.Model): + ''' gives someone access to create an account on the instance ''' + code = models.CharField(max_length=32, default=new_access_code) + expiry = models.DateTimeField(default=get_passowrd_reset_expiry) + user = models.OneToOneField(User, on_delete=models.CASCADE) + + def valid(self): + ''' make sure it hasn't expired or been used ''' + return self.expiry > timezone.now() + + @property + def link(self): + ''' formats the invite link ''' + return "https://{}/password-reset/{}".format(DOMAIN, self.code) diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index 337e04d78..6b162f9f2 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -13,13 +13,6 @@ CELERY_ACCEPT_CONTENT = ['application/json'] CELERY_TASK_SERIALIZER = 'json' CELERY_RESULT_SERIALIZER = 'json' -# emailing -EMAIL_HOST = env('EMAIL_HOST') -EMAIL_PORT = env('EMAIL_PORT') -EMAIL_HOST_USER = env('EMAIL_HOST_USER') -EMAIL_HOST_PASSWORD = env('EMAIL_HOST_PASSWORD') -EMAIL_USE_TLS = env('EMAIL_USE_TLS') - # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) diff --git a/bookwyrm/templates/password_reset.html b/bookwyrm/templates/password_reset.html new file mode 100644 index 000000000..ca3ae5005 --- /dev/null +++ b/bookwyrm/templates/password_reset.html @@ -0,0 +1,43 @@ +{% extends 'layout.html' %} +{% block content %} + +
+
+
+

Reset Password

+ {% for error in errors %} +

{{ error }}

+ {% endfor %} +
+ {% csrf_token %} + +
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+
+
+ +
+
+ {% include 'snippets/about.html' with site_settings=site_settings %} +
+
+ +
+{% endblock %} + diff --git a/bookwyrm/templates/password_reset_request.html b/bookwyrm/templates/password_reset_request.html new file mode 100644 index 000000000..f66d84a9b --- /dev/null +++ b/bookwyrm/templates/password_reset_request.html @@ -0,0 +1,29 @@ +{% extends 'layout.html' %} +{% block content %} + +
+
+
+

Reset Password

+ {% if message %}

{{ message }}

{% endif %} +

A link to reset your password will be sent to your email address

+
+ {% csrf_token %} +
+ +
+ +
+
+
+
+ +
+
+
+
+
+
+{% endblock %} + + diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py index d35ed377f..4d188d965 100644 --- a/bookwyrm/urls.py +++ b/bookwyrm/urls.py @@ -39,6 +39,8 @@ urlpatterns = [ # ui views re_path(r'^login/?$', views.login_page), re_path(r'^about/?$', views.about_page), + re_path(r'^password-reset/?$', views.password_reset_request), + re_path(r'^password-reset/(?P[A-Za-z0-9]+)/?$', views.password_reset), re_path(r'^invite/?$', views.manage_invites), re_path(r'^invite/(?P[A-Za-z0-9]+)/?$', views.invite_page), @@ -81,6 +83,9 @@ urlpatterns = [ re_path(r'^logout/?$', actions.user_logout), re_path(r'^user-login/?$', actions.user_login), re_path(r'^user-register/?$', actions.register), + re_path(r'^reset-password-request/?$', actions.password_reset_request), + re_path(r'^reset-password/?$', actions.password_reset), + re_path(r'^edit_profile/?$', actions.edit_profile), re_path(r'^import_data/?', actions.import_data), diff --git a/bookwyrm/view_actions.py b/bookwyrm/view_actions.py index 604fe3a74..c574d224a 100644 --- a/bookwyrm/view_actions.py +++ b/bookwyrm/view_actions.py @@ -13,6 +13,7 @@ from django.core.exceptions import PermissionDenied from bookwyrm import books_manager from bookwyrm import forms, models, outgoing from bookwyrm import goodreads_import +from bookwyrm.emailing import password_reset_email from bookwyrm.settings import DOMAIN from bookwyrm.views import get_user_from_username @@ -87,6 +88,51 @@ def user_logout(request): return redirect('/') +def password_reset_request(request): + ''' create a password reset token ''' + email = request.POST.get('email') + try: + user = models.User.objects.get(email=email) + except models.User.DoesNotExist: + return redirect('/password-reset') + + # remove any existing password reset cods for this user + models.PasswordReset.objects.filter(user=user).all().delete() + + # create a new reset code + code = models.PasswordReset.objects.create(user=user) + password_reset_email(code) + data = {'message': 'Password reset link sent to %s' % email} + return TemplateResponse(request, 'password_reset_request.html', data) + + + +def password_reset(request): + ''' allow a user to change their password ''' + try: + reset_code = models.PasswordReset.objects.get( + code=request.POST.get('reset-code') + ) + except models.PasswordReset.DoesNotExist: + data = {'errors': ['Invalid password reset link']} + return TemplateResponse(request, 'password_reset.html', data) + + user = reset_code.user + + new_password = request.POST.get('password') + confirm_password = request.POST.get('confirm-password') + + if new_password != confirm_password: + data = {'errors': ['Passwords do not match']} + return TemplateResponse(request, 'password_reset.html', data) + + user.set_password(new_password) + user.save() + login(request, user) + reset_code.delete() + return redirect('/') + + @login_required def edit_profile(request): ''' les get fancy with images ''' diff --git a/bookwyrm/views.py b/bookwyrm/views.py index 9c0513d10..fe66c460b 100644 --- a/bookwyrm/views.py +++ b/bookwyrm/views.py @@ -204,6 +204,29 @@ def about_page(request): return TemplateResponse(request, 'about.html', data) +def password_reset_request(request): + ''' invite management page ''' + return TemplateResponse(request, 'password_reset_request.html') + + +def password_reset(request, code): + ''' endpoint for sending invites ''' + if request.user.is_authenticated: + return redirect('/') + try: + reset_code = models.PasswordReset.objects.get(code=code) + if not reset_code.valid(): + raise PermissionDenied + except models.PasswordReset.DoesNotExist: + raise PermissionDenied + + return TemplateResponse( + request, + 'password_reset.html', + {'code': reset_code.code} + ) + + def invite_page(request, code): ''' endpoint for sending invites ''' if request.user.is_authenticated: diff --git a/celerywyrm/celery.py b/celerywyrm/celery.py index f566bbed1..28b4a2005 100644 --- a/celerywyrm/celery.py +++ b/celerywyrm/celery.py @@ -1,10 +1,10 @@ ''' configures celery for task management ''' from __future__ import absolute_import, unicode_literals -from . import settings - import os from celery import Celery +from . import settings + # set the default Django settings module for the 'celery' program. os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'celerywyrm.settings') @@ -19,7 +19,8 @@ app.config_from_object('django.conf:settings', namespace='CELERY') # Load task modules from all registered Django app configs. app.autodiscover_tasks() -app.autodiscover_tasks(['bookwyrm'], related_name='incoming') app.autodiscover_tasks(['bookwyrm'], related_name='broadcast') app.autodiscover_tasks(['bookwyrm'], related_name='books_manager') +app.autodiscover_tasks(['bookwyrm'], related_name='emailing') app.autodiscover_tasks(['bookwyrm'], related_name='goodreads_import') +app.autodiscover_tasks(['bookwyrm'], related_name='incoming') diff --git a/celerywyrm/settings.py b/celerywyrm/settings.py index a17ff8bb8..12688e060 100644 --- a/celerywyrm/settings.py +++ b/celerywyrm/settings.py @@ -15,6 +15,14 @@ from environs import Env env = Env() +# emailing +EMAIL_HOST = env('EMAIL_HOST') +EMAIL_PORT = env('EMAIL_PORT') +EMAIL_HOST_USER = env('EMAIL_HOST_USER') +EMAIL_HOST_PASSWORD = env('EMAIL_HOST_PASSWORD') +EMAIL_USE_TLS = env('EMAIL_USE_TLS') + + # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))