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 %} + +
{{ error }}
+ {% endfor %} + +{{ message }}
{% endif %} +A link to reset your password will be sent to your email address
+ +[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__)))