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',
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)),

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 .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 \

View file

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

View file

@ -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__)))

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
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<code>[A-Za-z0-9]+)/?$', views.password_reset),
re_path(r'^invite/?$', views.manage_invites),
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'^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),

View file

@ -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 '''

View file

@ -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:

View file

@ -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')

View file

@ -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__)))