From 9e87e810afebf185224b51d55239f597c2932393 Mon Sep 17 00:00:00 2001 From: Anna-Maria Meriniemi Date: Sun, 8 Oct 2017 04:34:40 +0300 Subject: [PATCH] [Config change] Password reset by email (#381) * Password reset by email Adds endpoint, templates, email templates, forms * Timeout password reset request in six hours --- config.example.py | 4 + nyaa/email.py | 1 + nyaa/forms.py | 53 ++++++++++--- nyaa/templates/email/reset-request.html | 24 ++++++ nyaa/templates/email/reset-request.txt | 5 ++ nyaa/templates/email/reset.html | 10 +++ nyaa/templates/email/reset.txt | 1 + nyaa/templates/login.html | 32 +++++++- nyaa/templates/password_reset.html | 31 ++++++++ nyaa/templates/password_reset_request.html | 38 +++++++++ nyaa/views/account.py | 92 +++++++++++++++++++++- nyaa/views/users.py | 14 +++- 12 files changed, 290 insertions(+), 15 deletions(-) create mode 100644 nyaa/templates/email/reset-request.html create mode 100644 nyaa/templates/email/reset-request.txt create mode 100644 nyaa/templates/email/reset.html create mode 100644 nyaa/templates/email/reset.txt create mode 100644 nyaa/templates/password_reset.html create mode 100644 nyaa/templates/password_reset_request.html diff --git a/config.example.py b/config.example.py index c046bcb..a237a95 100644 --- a/config.example.py +++ b/config.example.py @@ -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 = '***' diff --git a/nyaa/email.py b/nyaa/email.py index a14ebae..a74a631 100644 --- a/nyaa/email.py +++ b/nyaa/email.py @@ -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 diff --git a/nyaa/forms.py b/nyaa/forms.py index 623d68b..a0f4609 100644 --- a/nyaa/forms.py +++ b/nyaa/forms.py @@ -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') diff --git a/nyaa/templates/email/reset-request.html b/nyaa/templates/email/reset-request.html new file mode 100644 index 0000000..9716ac6 --- /dev/null +++ b/nyaa/templates/email/reset-request.html @@ -0,0 +1,24 @@ + + + {{ config.GLOBAL_SITE_NAME }} password reset request + + + +
+ {{ user.username }}, you've requested to reset your password on {{ config.GLOBAL_SITE_NAME }}. Click the link below to change your password: +
+
+ {{ reset_link }} +
+
+ If you did not request a password reset, you may ignore this email. +
+ + diff --git a/nyaa/templates/email/reset-request.txt b/nyaa/templates/email/reset-request.txt new file mode 100644 index 0000000..ff7fbd3 --- /dev/null +++ b/nyaa/templates/email/reset-request.txt @@ -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. \ No newline at end of file diff --git a/nyaa/templates/email/reset.html b/nyaa/templates/email/reset.html new file mode 100644 index 0000000..afec8a7 --- /dev/null +++ b/nyaa/templates/email/reset.html @@ -0,0 +1,10 @@ + + + Your {{ config.GLOBAL_SITE_NAME }} password has been reset + + +
+ {{ user.username }}, your password on {{ config.GLOBAL_SITE_NAME }} has just been successfully reset from a password-reset link. +
+ + diff --git a/nyaa/templates/email/reset.txt b/nyaa/templates/email/reset.txt new file mode 100644 index 0000000..3402be5 --- /dev/null +++ b/nyaa/templates/email/reset.txt @@ -0,0 +1 @@ +{{ user.username }}, your password on {{ config.GLOBAL_SITE_NAME }} has just been successfully reset from a password-reset link. \ No newline at end of file diff --git a/nyaa/templates/login.html b/nyaa/templates/login.html index 5899a0d..a02cb75 100644 --- a/nyaa/templates/login.html +++ b/nyaa/templates/login.html @@ -18,7 +18,37 @@
- {{ 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 %} +
+ {% else %} +
+ {% endif %} + {{ form.password.label(class='control-label') }} + + {% if config.ALLOW_PASSWORD_RESET: %} + + Forgot your password? + + {% endif%} + + {{ form.password(title=form.password.description, class_='form-control') | safe }} + {% if form.password.errors %} +
+ {% if form.password.errors|length < 2 %} + {% for error in form.password.errors %} + {{ error }} + {% endfor %} + {% else %} +
    + {% for error in form.password.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} +
+ {% endif %} +
diff --git a/nyaa/templates/password_reset.html b/nyaa/templates/password_reset.html new file mode 100644 index 0000000..6d90ad0 --- /dev/null +++ b/nyaa/templates/password_reset.html @@ -0,0 +1,31 @@ +{% extends "layout.html" %} +{% block title %}Password reset :: {{ config.SITE_NAME }}{% endblock %} +{% block metatags %} + +{% endblock %} +{% block body %} +{% from "_formhelpers.html" import render_field %} + +

Password reset

+
+ {{ form.csrf_token }} + +
+
+ {{ render_field(form.password, class_='form-control', placeholder='Password') }} +
+
+ +
+
+ {{ render_field(form.password_confirm, class_='form-control', placeholder='Password (confirm)') }} +
+
+ +
+
+ +
+
+
+{% endblock %} diff --git a/nyaa/templates/password_reset_request.html b/nyaa/templates/password_reset_request.html new file mode 100644 index 0000000..5e9b83b --- /dev/null +++ b/nyaa/templates/password_reset_request.html @@ -0,0 +1,38 @@ +{% extends "layout.html" %} +{% block title %}Password reset request :: {{ config.SITE_NAME }}{% endblock %} +{% block metatags %} + +{% endblock %} +{% block body %} +{% from "_formhelpers.html" import render_field %} + +

Request password reset

+
+ {{ form.csrf_token }} + +
+
+ {{ render_field(form.email, class_='form-control', placeholder='Email address') }} +
+
+ + {% if config.USE_RECAPTCHA %} +
+
+ {% for error in form.recaptcha.errors %} + {{ error }} + {% endfor %} + {{ form.recaptcha }} +
+
+
+ + {% endif %} + +
+
+ +
+
+
+{% endblock %} diff --git a/nyaa/views/account.py b/nyaa/views/account.py index 7fe5b28..4727198 100644 --- a/nyaa/views/account.py +++ b/nyaa/views/account.py @@ -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/', 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) diff --git a/nyaa/views/users.py b/nyaa/views/users.py index 71b53ff..838daf8 100644 --- a/nyaa/views/users.py +++ b/nyaa/views/users.py @@ -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)