mirror of
https://gitlab.com/SIGBUS/nyaa.git
synced 2024-12-22 15:30:01 +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
|
# Show seeds/peers/completions in torrent list/page
|
||||||
ENABLE_SHOW_STATS = True
|
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 keys (https://www.google.com/recaptcha)
|
||||||
RECAPTCHA_PUBLIC_KEY = '***'
|
RECAPTCHA_PUBLIC_KEY = '***'
|
||||||
RECAPTCHA_PRIVATE_KEY = '***'
|
RECAPTCHA_PRIVATE_KEY = '***'
|
||||||
|
|
|
@ -12,6 +12,7 @@ from nyaa import models
|
||||||
class EmailHolder(object):
|
class EmailHolder(object):
|
||||||
''' Holds email subject, recipient and content, so we have a general class for
|
''' Holds email subject, recipient and content, so we have a general class for
|
||||||
all mail backends. '''
|
all mail backends. '''
|
||||||
|
|
||||||
def __init__(self, subject=None, recipient=None, text=None, html=None):
|
def __init__(self, subject=None, recipient=None, text=None, html=None):
|
||||||
self.subject = subject
|
self.subject = subject
|
||||||
self.recipient = recipient # models.User or string
|
self.recipient = recipient # models.User or string
|
||||||
|
|
|
@ -50,6 +50,25 @@ def stop_on_validation_error(f):
|
||||||
return decorator
|
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(
|
_username_validator = Regexp(
|
||||||
r'^[a-zA-Z0-9_\-]+$',
|
r'^[a-zA-Z0-9_\-]+$',
|
||||||
message='Your username must only consist of alphanumerics and _- (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()])
|
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):
|
class RegisterForm(FlaskForm):
|
||||||
username = StringField('Username', [
|
username = StringField('Username', [
|
||||||
DataRequired(),
|
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):
|
class UploadForm(FlaskForm):
|
||||||
torrent_file = FileField('Torrent file', [
|
torrent_file = FileField('Torrent file', [
|
||||||
FileRequired()
|
FileRequired()
|
||||||
|
@ -262,7 +291,7 @@ class UploadForm(FlaskForm):
|
||||||
'%(max)d at most.')
|
'%(max)d at most.')
|
||||||
])
|
])
|
||||||
|
|
||||||
recaptcha = RecaptchaField(validators=[recaptcha_validator_shim])
|
recaptcha = RecaptchaField(validators=[upload_recaptcha_validator_shim])
|
||||||
|
|
||||||
category = DisabledSelectField('Category')
|
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="row">
|
||||||
<div class="form-group col-md-4">
|
<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>
|
||||||
</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 datetime import datetime
|
||||||
from ipaddress import ip_address
|
from ipaddress import ip_address
|
||||||
|
|
||||||
|
@ -5,7 +7,8 @@ import flask
|
||||||
|
|
||||||
from nyaa import email, forms, models
|
from nyaa import email, forms, models
|
||||||
from nyaa.extensions import db
|
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
|
app = flask.current_app
|
||||||
bp = flask.Blueprint('account', __name__)
|
bp = flask.Blueprint('account', __name__)
|
||||||
|
@ -95,6 +98,61 @@ def register():
|
||||||
return flask.render_template('register.html', form=form)
|
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'])
|
@bp.route('/profile', methods=['GET', 'POST'])
|
||||||
def profile():
|
def profile():
|
||||||
if not flask.g.user:
|
if not flask.g.user:
|
||||||
|
@ -162,3 +220,35 @@ def send_verification_email(user):
|
||||||
)
|
)
|
||||||
|
|
||||||
email.send_email(email_msg)
|
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 math
|
||||||
|
import time
|
||||||
from ipaddress import ip_address
|
from ipaddress import ip_address
|
||||||
|
|
||||||
import flask
|
import flask
|
||||||
|
@ -10,7 +12,7 @@ from nyaa import forms, models
|
||||||
from nyaa.extensions import db
|
from nyaa.extensions import db
|
||||||
from nyaa.search import (DEFAULT_MAX_SEARCH_RESULT, DEFAULT_PER_PAGE, SERACH_PAGINATE_DISPLAY_MSG,
|
from nyaa.search import (DEFAULT_MAX_SEARCH_RESULT, DEFAULT_PER_PAGE, SERACH_PAGINATE_DISPLAY_MSG,
|
||||||
_generate_query_string, search_db, search_elastic)
|
_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
|
app = flask.current_app
|
||||||
bp = flask.Blueprint('users', __name__)
|
bp = flask.Blueprint('users', __name__)
|
||||||
|
@ -251,3 +253,13 @@ def get_activation_link(user):
|
||||||
s = get_serializer()
|
s = get_serializer()
|
||||||
payload = s.dumps(user.id)
|
payload = s.dumps(user.id)
|
||||||
return flask.url_for('users.activate_user', payload=payload, _external=True)
|
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