mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2024-12-30 12:00:36 +00:00
Forgot password flow
This commit is contained in:
parent
f8f4d09ede
commit
d4b18678bd
12 changed files with 210 additions and 14 deletions
|
@ -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)),
|
||||||
|
|
25
bookwyrm/migrations/0049_passwordreset.py
Normal file
25
bookwyrm/migrations/0049_passwordreset.py
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
|
@ -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 \
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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__)))
|
||||||
|
|
||||||
|
|
43
bookwyrm/templates/password_reset.html
Normal file
43
bookwyrm/templates/password_reset.html
Normal 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 %}
|
||||||
|
|
29
bookwyrm/templates/password_reset_request.html
Normal file
29
bookwyrm/templates/password_reset_request.html
Normal 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 %}
|
||||||
|
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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 '''
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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__)))
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue