[Config change] Password reset by email (#381)

* Password reset by email

Adds endpoint, templates, email templates, forms

* Timeout password reset request in six hours
This commit is contained in:
Anna-Maria Meriniemi 2017-10-08 04:34:40 +03:00 committed by GitHub
parent 6d09920abd
commit 9e87e810af
12 changed files with 290 additions and 15 deletions

View File

@ -40,6 +40,10 @@ USE_MYSQL = True
# Show seeds/peers/completions in torrent list/page
ENABLE_SHOW_STATS = True
# Enable password recovery (by reset link to given email address)
# Depends on email support!
ALLOW_PASSWORD_RESET = True
# Recaptcha keys (https://www.google.com/recaptcha)
RECAPTCHA_PUBLIC_KEY = '***'
RECAPTCHA_PRIVATE_KEY = '***'

View File

@ -12,6 +12,7 @@ from nyaa import models
class EmailHolder(object):
''' Holds email subject, recipient and content, so we have a general class for
all mail backends. '''
def __init__(self, subject=None, recipient=None, text=None, html=None):
self.subject = subject
self.recipient = recipient # models.User or string

View File

@ -50,6 +50,25 @@ def stop_on_validation_error(f):
return decorator
def recaptcha_validator_shim(form, field):
if app.config['USE_RECAPTCHA']:
return RecaptchaValidator()(form, field)
else:
# Always pass validating the recaptcha field if disabled
return True
def upload_recaptcha_validator_shim(form, field):
''' Selectively does a recaptcha validation '''
if app.config['USE_RECAPTCHA']:
# Recaptcha anonymous and new users
if not flask.g.user or flask.g.user.age < app.config['ACCOUNT_RECAPTCHA_AGE']:
return RecaptchaValidator()(form, field)
else:
# Always pass validating the recaptcha field if disabled
return True
_username_validator = Regexp(
r'^[a-zA-Z0-9_\-]+$',
message='Your username must only consist of alphanumerics and _- (a-zA-Z0-9_-)')
@ -60,6 +79,27 @@ class LoginForm(FlaskForm):
password = PasswordField('Password', [DataRequired()])
class PasswordResetRequestForm(FlaskForm):
email = StringField('Email address', [
Email(),
DataRequired(),
Length(min=5, max=128)
])
recaptcha = RecaptchaField(validators=[recaptcha_validator_shim])
class PasswordResetForm(FlaskForm):
password = PasswordField('Password', [
DataRequired(),
EqualTo('password_confirm', message='Passwords must match'),
Length(min=6, max=1024,
message='Password must be at least %(min)d characters long.')
])
password_confirm = PasswordField('Password (confirm)')
class RegisterForm(FlaskForm):
username = StringField('Username', [
DataRequired(),
@ -239,17 +279,6 @@ class BanForm(FlaskForm):
])
def recaptcha_validator_shim(form, field):
''' Selectively does a recaptcha validation '''
if app.config['USE_RECAPTCHA']:
# Recaptcha anonymous and new users
if not flask.g.user or flask.g.user.age < app.config['ACCOUNT_RECAPTCHA_AGE']:
return RecaptchaValidator()(form, field)
else:
# Always pass validating the recaptcha field if disabled
return True
class UploadForm(FlaskForm):
torrent_file = FileField('Torrent file', [
FileRequired()
@ -262,7 +291,7 @@ class UploadForm(FlaskForm):
'%(max)d at most.')
])
recaptcha = RecaptchaField(validators=[recaptcha_validator_shim])
recaptcha = RecaptchaField(validators=[upload_recaptcha_validator_shim])
category = DisabledSelectField('Category')

View File

@ -0,0 +1,24 @@
<html>
<head>
<title>{{ config.GLOBAL_SITE_NAME }} password reset request</title>
<style type="text/css">
.well {
display: inline-block;
border-radius: 5px;
padding: 10px;
background-color: rgb(240, 240, 240)
}
</style>
</head>
<body>
<div>
{{ user.username }}, you've requested to reset your password on {{ config.GLOBAL_SITE_NAME }}. Click the link below to change your password:
</div>
<div class="well">
<a href="{{ reset_link }}">{{ reset_link }}</a>
</div>
<div>
If you did not request a password reset, you may ignore this email.
</div>
</body>
</html>

View File

@ -0,0 +1,5 @@
{{ user.username }}, you've requested to reset your password on {{ config.GLOBAL_SITE_NAME }}. Open the link below to change your password:
{{ reset_link }}
If you did not request a password reset, you may ignore this email.

View File

@ -0,0 +1,10 @@
<html>
<head>
<title>Your {{ config.GLOBAL_SITE_NAME }} password has been reset</title>
</head>
<body>
<div>
{{ user.username }}, your password on {{ config.GLOBAL_SITE_NAME }} has just been successfully reset from a password-reset link.
</div>
</body>
</html>

View File

@ -0,0 +1 @@
{{ user.username }}, your password on {{ config.GLOBAL_SITE_NAME }} has just been successfully reset from a password-reset link.

View File

@ -18,7 +18,37 @@
<div class="row">
<div class="form-group col-md-4">
{{ render_field(form.password, class_='form-control', placeholder='Password') }}
{# This is just render_field() exploded so that we can add the password link after the label #}
{% if form.password.errors %}
<div class="form-group has-error">
{% else %}
<div class="form-group">
{% endif %}
{{ form.password.label(class='control-label') }}
{% if config.ALLOW_PASSWORD_RESET: %}
<small>
<a href="{{ url_for('account.password_reset') }}">Forgot your password?</a>
</small>
{% endif%}
{{ form.password(title=form.password.description, class_='form-control') | safe }}
{% if form.password.errors %}
<div class="help-block">
{% if form.password.errors|length < 2 %}
{% for error in form.password.errors %}
{{ error }}
{% endfor %}
{% else %}
<ul>
{% for error in form.password.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>

View File

@ -0,0 +1,31 @@
{% extends "layout.html" %}
{% block title %}Password reset :: {{ config.SITE_NAME }}{% endblock %}
{% block metatags %}
<meta property="og:description" content="Reset your password on {{ config.SITE_NAME }}!">
{% endblock %}
{% block body %}
{% from "_formhelpers.html" import render_field %}
<h1>Password reset</h1>
<form method="POST">
{{ form.csrf_token }}
<div class="row">
<div class="form-group col-md-4">
{{ render_field(form.password, class_='form-control', placeholder='Password') }}
</div>
</div>
<div class="row">
<div class="form-group col-md-4">
{{ render_field(form.password_confirm, class_='form-control', placeholder='Password (confirm)') }}
</div>
</div>
<div class="row">
<div class="col-md-4">
<input type="submit" value="Reset password" class="btn btn-primary">
</div>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,38 @@
{% extends "layout.html" %}
{% block title %}Password reset request :: {{ config.SITE_NAME }}{% endblock %}
{% block metatags %}
<meta property="og:description" content="Request to reset your password on {{ config.SITE_NAME }}!">
{% endblock %}
{% block body %}
{% from "_formhelpers.html" import render_field %}
<h1>Request password reset</h1>
<form method="POST">
{{ form.csrf_token }}
<div class="row">
<div class="form-group col-md-4">
{{ render_field(form.email, class_='form-control', placeholder='Email address') }}
</div>
</div>
{% if config.USE_RECAPTCHA %}
<div class="row">
<div class="col-md-4">
{% for error in form.recaptcha.errors %}
{{ error }}
{% endfor %}
{{ form.recaptcha }}
</div>
</div>
<br>
{% endif %}
<div class="row">
<div class="col-md-4">
<input type="submit" value="Request password reset" class="btn btn-primary">
</div>
</div>
</form>
{% endblock %}

View File

@ -1,3 +1,5 @@
import binascii
import time
from datetime import datetime
from ipaddress import ip_address
@ -5,7 +7,8 @@ import flask
from nyaa import email, forms, models
from nyaa.extensions import db
from nyaa.views.users import get_activation_link
from nyaa.utils import sha1_hash
from nyaa.views.users import get_activation_link, get_password_reset_link, get_serializer
app = flask.current_app
bp = flask.Blueprint('account', __name__)
@ -95,6 +98,61 @@ def register():
return flask.render_template('register.html', form=form)
@bp.route('/password-reset/<payload>', methods=['GET', 'POST'])
@bp.route('/password-reset', methods=['GET', 'POST'])
def password_reset(payload=None):
if not app.config['ALLOW_PASSWORD_RESET']:
return flask.abort(404)
if flask.g.user:
return flask.redirect(redirect_url())
if payload is None:
form = forms.PasswordResetRequestForm(flask.request.form)
if flask.request.method == 'POST' and form.validate():
user = models.User.by_email(form.email.data.strip())
if user:
send_password_reset_request_email(user)
flask.flash(flask.Markup(
'A password reset request was sent to the provided email, '
'if a matching account was found.'), 'info')
return flask.redirect(flask.url_for('main.home'))
return flask.render_template('password_reset_request.html', form=form)
else:
s = get_serializer()
try:
request_timestamp, pw_hash, user_id = s.loads(payload)
except:
return flask.abort(404)
user = models.User.by_id(user_id)
if not user:
return flask.abort(404)
# Timeout after six hours
if (time.time() - request_timestamp) > 6 * 3600:
return flask.abort(404)
sha1_password_hash_hash = binascii.hexlify(sha1_hash(user.password_hash.hash)).decode()
if pw_hash != sha1_password_hash_hash:
return flask.abort(404)
form = forms.PasswordResetForm(flask.request.form)
if flask.request.method == 'POST' and form.validate():
user.password_hash = form.password.data
db.session.add(user)
db.session.commit()
send_password_reset_email(user)
flask.flash(flask.Markup('Your password was reset. Log in now.'), 'info')
return flask.redirect(flask.url_for('account.login'))
return flask.render_template('password_reset.html', form=form)
@bp.route('/profile', methods=['GET', 'POST'])
def profile():
if not flask.g.user:
@ -162,3 +220,35 @@ def send_verification_email(user):
)
email.send_email(email_msg)
def send_password_reset_email(user):
''' Alert user that their password has been successfully reset '''
email_msg = email.EmailHolder(
subject='Your {} password has been reset'.format(app.config['GLOBAL_SITE_NAME']),
recipient=user,
text=flask.render_template('email/reset.txt', user=user),
html=flask.render_template('email/reset.html', user=user),
)
email.send_email(email_msg)
def send_password_reset_request_email(user):
''' Send user a password reset link '''
reset_link = get_password_reset_link(user)
tmpl_context = {
'reset_link': reset_link,
'user': user
}
email_msg = email.EmailHolder(
subject='{} password reset request'.format(app.config['GLOBAL_SITE_NAME']),
recipient=user,
text=flask.render_template('email/reset-request.txt', **tmpl_context),
html=flask.render_template('email/reset-request.html', **tmpl_context),
)
email.send_email(email_msg)

View File

@ -1,4 +1,6 @@
import binascii
import math
import time
from ipaddress import ip_address
import flask
@ -10,7 +12,7 @@ from nyaa import forms, models
from nyaa.extensions import db
from nyaa.search import (DEFAULT_MAX_SEARCH_RESULT, DEFAULT_PER_PAGE, SERACH_PAGINATE_DISPLAY_MSG,
_generate_query_string, search_db, search_elastic)
from nyaa.utils import chain_get
from nyaa.utils import chain_get, sha1_hash
app = flask.current_app
bp = flask.Blueprint('users', __name__)
@ -251,3 +253,13 @@ def get_activation_link(user):
s = get_serializer()
payload = s.dumps(user.id)
return flask.url_for('users.activate_user', payload=payload, _external=True)
def get_password_reset_link(user):
# This mess to not to have static password reset links
# Maybe not the best idea? But this should not be a security risk, and it works.
password_hash_hash = binascii.hexlify(sha1_hash(user.password_hash.hash)).decode()
s = get_serializer()
payload = s.dumps((time.time(), password_hash_hash, user.id))
return flask.url_for('account.password_reset', payload=payload, _external=True)