mirror of
https://gitlab.com/SIGBUS/nyaa.git
synced 2024-12-22 10:19:59 +00:00
[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:
parent
6d09920abd
commit
9e87e810af
|
@ -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 = '***'
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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')
|
||||
|
||||
|
|
24
nyaa/templates/email/reset-request.html
Normal file
24
nyaa/templates/email/reset-request.html
Normal 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>
|
5
nyaa/templates/email/reset-request.txt
Normal file
5
nyaa/templates/email/reset-request.txt
Normal 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.
|
10
nyaa/templates/email/reset.html
Normal file
10
nyaa/templates/email/reset.html
Normal 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>
|
1
nyaa/templates/email/reset.txt
Normal file
1
nyaa/templates/email/reset.txt
Normal file
|
@ -0,0 +1 @@
|
|||
{{ user.username }}, your password on {{ config.GLOBAL_SITE_NAME }} has just been successfully reset from a password-reset link.
|
|
@ -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>
|
||||
|
||||
|
|
31
nyaa/templates/password_reset.html
Normal file
31
nyaa/templates/password_reset.html
Normal 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 %}
|
38
nyaa/templates/password_reset_request.html
Normal file
38
nyaa/templates/password_reset_request.html
Normal 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 %}
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue