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:
+
+
+
+ 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 @@
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
+
+{% 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
+
+{% 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)