Forgot password flow

This commit is contained in:
Mouse Reeve 2020-10-02 13:32:19 -07:00
parent f8f4d09ede
commit d4b18678bd
12 changed files with 210 additions and 14 deletions

View file

@ -15,7 +15,7 @@ class Migration(migrations.Migration):
name='SiteInvite', name='SiteInvite',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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)), ('expiry', models.DateTimeField(blank=True, null=True)),
('use_limit', models.IntegerField(blank=True, null=True)), ('use_limit', models.IntegerField(blank=True, null=True)),
('times_used', models.IntegerField(default=0)), ('times_used', models.IntegerField(default=0)),

View file

@ -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)),
],
),
]

View file

@ -13,7 +13,7 @@ from .user import User
from .federated_server import FederatedServer from .federated_server import FederatedServer
from .import_job import ImportJob, ImportItem 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) cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
activity_models = {c[0]: c[1].activity_serializer for c in cls_members \ activity_models = {c[0]: c[1].activity_serializer for c in cls_members \

View file

@ -1,5 +1,6 @@
''' the particulars for this instance of BookWyrm ''' ''' the particulars for this instance of BookWyrm '''
import base64 import base64
import datetime
from Crypto import Random from Crypto import Random
from django.db import models from django.db import models
@ -27,13 +28,13 @@ class SiteSettings(models.Model):
default_settings.save() default_settings.save()
return default_settings return default_settings
def new_invite_code(): def new_access_code():
''' the identifier for a user invite ''' ''' the identifier for a user invite '''
return base64.b32encode(Random.get_random_bytes(5)).decode('ascii') return base64.b32encode(Random.get_random_bytes(5)).decode('ascii')
class SiteInvite(models.Model): class SiteInvite(models.Model):
''' gives someone access to create an account on the instance ''' ''' 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) expiry = models.DateTimeField(blank=True, null=True)
use_limit = models.IntegerField(blank=True, null=True) use_limit = models.IntegerField(blank=True, null=True)
times_used = models.IntegerField(default=0) times_used = models.IntegerField(default=0)
@ -49,3 +50,25 @@ class SiteInvite(models.Model):
def link(self): def link(self):
''' formats the invite link ''' ''' formats the invite link '''
return "https://{}/invite/{}".format(DOMAIN, self.code) 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)

View file

@ -13,13 +13,6 @@ CELERY_ACCEPT_CONTENT = ['application/json']
CELERY_TASK_SERIALIZER = 'json' CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_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, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

View file

@ -0,0 +1,43 @@
{% extends 'layout.html' %}
{% block content %}
<div class="columns">
<div class="column">
<div class="block">
<h2 class="title">Reset Password</h2>
{% for error in errors %}
<p class="is-danger">{{ error }}</p>
{% endfor %}
<form name="reset-password" method="post" action="/reset-password">
{% csrf_token %}
<input type="hidden" name="reset-code" value="{{ code }}">
<div class="field">
<label class="label" for="id_password">Password:</label>
<div class="control">
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password">
</div>
</div>
<div class="field">
<label class="label" for="id_confirm_password">Confirm password:</label>
<div class="control">
<input type="password" name="confirm-password" maxlength="128" class="input" required="" id="id_confirm_password">
</div>
</div>
<div class="field is-grouped">
<div class="control">
<button class="button is-primary" type="submit">Confirm</button>
</div>
</div>
</form>
</div>
</div>
<div class="column">
<div class="block">
{% include 'snippets/about.html' with site_settings=site_settings %}
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,29 @@
{% extends 'layout.html' %}
{% block content %}
<div class="columns is-centered">
<div class="column is-half">
<div class="block">
<h2 class="title">Reset Password</h2>
{% if message %}<p>{{ message }}</p>{% endif %}
<p>A link to reset your password will be sent to your email address</p>
<form name="reset-password" method="post" action="/reset-password-request">
{% csrf_token %}
<div class="field">
<label class="label" for="id_email_register">Email address:</label>
<div class="control">
<input type="email" name="email" maxlength="254" class="input" id="id_email_register">
</div>
</div>
<div class="field is-grouped">
<div class="control">
<button class="button is-primary" type="submit">Reset password</button>
</div>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View file

@ -39,6 +39,8 @@ urlpatterns = [
# ui views # ui views
re_path(r'^login/?$', views.login_page), re_path(r'^login/?$', views.login_page),
re_path(r'^about/?$', views.about_page), re_path(r'^about/?$', views.about_page),
re_path(r'^password-reset/?$', views.password_reset_request),
re_path(r'^password-reset/(?P<code>[A-Za-z0-9]+)/?$', views.password_reset),
re_path(r'^invite/?$', views.manage_invites), re_path(r'^invite/?$', views.manage_invites),
re_path(r'^invite/(?P<code>[A-Za-z0-9]+)/?$', views.invite_page), re_path(r'^invite/(?P<code>[A-Za-z0-9]+)/?$', views.invite_page),
@ -81,6 +83,9 @@ urlpatterns = [
re_path(r'^logout/?$', actions.user_logout), re_path(r'^logout/?$', actions.user_logout),
re_path(r'^user-login/?$', actions.user_login), re_path(r'^user-login/?$', actions.user_login),
re_path(r'^user-register/?$', actions.register), 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'^edit_profile/?$', actions.edit_profile),
re_path(r'^import_data/?', actions.import_data), re_path(r'^import_data/?', actions.import_data),

View file

@ -13,6 +13,7 @@ from django.core.exceptions import PermissionDenied
from bookwyrm import books_manager from bookwyrm import books_manager
from bookwyrm import forms, models, outgoing from bookwyrm import forms, models, outgoing
from bookwyrm import goodreads_import from bookwyrm import goodreads_import
from bookwyrm.emailing import password_reset_email
from bookwyrm.settings import DOMAIN from bookwyrm.settings import DOMAIN
from bookwyrm.views import get_user_from_username from bookwyrm.views import get_user_from_username
@ -87,6 +88,51 @@ def user_logout(request):
return redirect('/') 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 @login_required
def edit_profile(request): def edit_profile(request):
''' les get fancy with images ''' ''' les get fancy with images '''

View file

@ -204,6 +204,29 @@ def about_page(request):
return TemplateResponse(request, 'about.html', data) 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): def invite_page(request, code):
''' endpoint for sending invites ''' ''' endpoint for sending invites '''
if request.user.is_authenticated: if request.user.is_authenticated:

View file

@ -1,10 +1,10 @@
''' configures celery for task management ''' ''' configures celery for task management '''
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
from . import settings
import os import os
from celery import Celery from celery import Celery
from . import settings
# set the default Django settings module for the 'celery' program. # set the default Django settings module for the 'celery' program.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'celerywyrm.settings') 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. # Load task modules from all registered Django app configs.
app.autodiscover_tasks() app.autodiscover_tasks()
app.autodiscover_tasks(['bookwyrm'], related_name='incoming')
app.autodiscover_tasks(['bookwyrm'], related_name='broadcast') app.autodiscover_tasks(['bookwyrm'], related_name='broadcast')
app.autodiscover_tasks(['bookwyrm'], related_name='books_manager') 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='goodreads_import')
app.autodiscover_tasks(['bookwyrm'], related_name='incoming')

View file

@ -15,6 +15,14 @@ from environs import Env
env = 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, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))