From c716c7c3ba7fed8c3956ec21acfde2439bbad36f Mon Sep 17 00:00:00 2001
From: Lunaresk
Date: Sun, 13 Nov 2022 18:20:12 +0100
Subject: [PATCH] feat: password recovery Implemented the "Forgot Password"
field with functionalities. If you're self-hosting, you need to use some
variables, which will be explained at a later point in the readme and on the
docker page.
---
app/__init__.py | 3 ++
app/auth/email.py | 14 ++++++++
app/auth/forms.py | 12 ++++++-
app/auth/routes.py | 34 +++++++++++++++++--
app/models.py | 17 ++++++++++
app/templates/auth/login.html | 4 +++
app/templates/auth/reset_password.html | 23 +++++++++++++
.../auth/reset_password_request.html | 16 +++++++++
app/templates/email/reset_password.html | 12 +++++++
app/templates/email/reset_password.txt | 11 ++++++
app/utils/email.py | 15 ++++++++
configs/config.py | 6 ++++
12 files changed, 164 insertions(+), 3 deletions(-)
create mode 100644 app/auth/email.py
create mode 100644 app/templates/auth/reset_password.html
create mode 100644 app/templates/auth/reset_password_request.html
create mode 100644 app/templates/email/reset_password.html
create mode 100644 app/templates/email/reset_password.txt
create mode 100644 app/utils/email.py
diff --git a/app/__init__.py b/app/__init__.py
index cc38a61..c68fb02 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -3,6 +3,7 @@ from flask import Flask
from flask_bootstrap import Bootstrap
from flask_login import LoginManager
from flask_sqlalchemy import SQLAlchemy
+from flask_mail import Mail
from flask_migrate import Migrate
from logging import getLogger
from logging.config import fileConfig
@@ -28,6 +29,7 @@ bootstrap = Bootstrap()
db = SQLAlchemy()
login = LoginManager()
login.login_view = 'auth.web_login'
+mail = Mail()
migrate = Migrate()
@@ -37,6 +39,7 @@ def create_app(config_class=Config):
bootstrap.init_app(app)
db.init_app(app)
login.init_app(app)
+ mail.init_app(app)
migrate.init_app(app, db, render_as_batch=True)
from app.auth import bp as auth_bp
diff --git a/app/auth/email.py b/app/auth/email.py
new file mode 100644
index 0000000..3bed0ee
--- /dev/null
+++ b/app/auth/email.py
@@ -0,0 +1,14 @@
+from flask import current_app
+from app.utils.email import send_email
+from app.utils.routes_utils import render_custom_template as render_template
+
+
+def send_password_reset_email(user):
+ token = user.get_reset_password_token()
+ send_email('[Scan2Kasse] Reset Your Password',
+ sender=current_app.config['ADMINS'][0],
+ recipients=[user.email],
+ text_body=render_template('email/reset_password.txt',
+ user=user, token=token),
+ html_body=render_template('email/reset_password.html',
+ user=user, token=token))
\ No newline at end of file
diff --git a/app/auth/forms.py b/app/auth/forms.py
index 7594222..8dfb21b 100644
--- a/app/auth/forms.py
+++ b/app/auth/forms.py
@@ -24,4 +24,14 @@ class RegistrationForm(FlaskForm):
def validate_email(self, email):
user = User.query.filter_by(email=email.data).first()
if user is not None:
- raise ValidationError('Please use a different email address.')
\ No newline at end of file
+ raise ValidationError('Please use a different email address.')
+
+class ResetPasswordRequestForm(FlaskForm):
+ email = StringField('Email', validators=[DataRequired(), Email()])
+ submit = SubmitField('Request Password Reset', render_kw={"class": "btn btn-primary mt-3"})
+
+class ResetPasswordForm(FlaskForm):
+ password = PasswordField('Password', validators=[DataRequired()])
+ password2 = PasswordField(
+ 'Repeat Password', validators=[DataRequired(), EqualTo('password')])
+ submit = SubmitField('Request Password Reset', render_kw={"class": "btn btn-primary mt-3"})
\ No newline at end of file
diff --git a/app/auth/routes.py b/app/auth/routes.py
index 2f50377..f2c810e 100644
--- a/app/auth/routes.py
+++ b/app/auth/routes.py
@@ -1,6 +1,7 @@
from app import db
from app.auth import bp
-from app.auth.forms import LoginForm, RegistrationForm
+from app.auth.email import send_password_reset_email
+from app.auth.forms import LoginForm, RegistrationForm, ResetPasswordForm, ResetPasswordRequestForm
from app.models import User
from app.utils.routes_utils import render_custom_template as render_template
from flask import flash, redirect, request, url_for
@@ -41,4 +42,33 @@ def web_login():
@bp.route(f'/logout')
def web_logout():
logout_user()
- return redirect(url_for('main.index'))
\ No newline at end of file
+ return redirect(url_for('main.index'))
+
+@bp.route('/reset_password_request', methods=['GET', 'POST'])
+def reset_password_request():
+ if current_user.is_authenticated:
+ return redirect(url_for('index'))
+ form = ResetPasswordRequestForm()
+ if form.validate_on_submit():
+ user = User.query.filter_by(email=form.email.data).first()
+ if user:
+ send_password_reset_email(user)
+ flash('Check your email for the instructions to reset your password')
+ return redirect(url_for('auth.web_login'))
+ return render_template('auth/reset_password_request.html',
+ title='Reset Password', form=form)
+
+@bp.route('/reset_password/', methods=['GET', 'POST'])
+def reset_password(token):
+ if current_user.is_authenticated:
+ return redirect(url_for('index'))
+ user = User.verify_reset_password_token(token)
+ if not user:
+ return redirect(url_for('index'))
+ form = ResetPasswordForm()
+ if form.validate_on_submit():
+ user.set_password(form.password.data)
+ db.session.commit()
+ flash('Your password has been reset.')
+ return redirect(url_for('auth.web_login'))
+ return render_template('auth/reset_password.html', form=form)
\ No newline at end of file
diff --git a/app/models.py b/app/models.py
index e4e1f03..937b453 100644
--- a/app/models.py
+++ b/app/models.py
@@ -1,6 +1,9 @@
+import jwt
from app import db, login
from datetime import date
+from flask import current_app
from flask_login import UserMixin
+from time import time
from werkzeug.security import generate_password_hash, check_password_hash
item_category = db.Table("item_category",
@@ -23,6 +26,20 @@ class User(UserMixin, db.Model):
def check_password(self, password):
return check_password_hash(self.password_hash, password)
+ def get_reset_password_token(self, expires_in=600):
+ return jwt.encode(
+ {'reset_password': self.id, 'exp': time() + expires_in},
+ current_app.config['SECRET_KEY'], algorithm='HS256')
+
+ @staticmethod
+ def verify_reset_password_token(token):
+ try:
+ id = jwt.decode(token, current_app.config['SECRET_KEY'],
+ algorithms=['HS256'])['reset_password']
+ except:
+ return
+ return User.query.get(id)
+
def __repr__(self) -> str:
return f""
diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html
index df3621a..e6322ef 100644
--- a/app/templates/auth/login.html
+++ b/app/templates/auth/login.html
@@ -16,4 +16,8 @@
{{ wtf.form_field(form.submit, class=form.submit.render_kw["class"]) }}
+
+ Forgot Your Password?
+ Click to Reset It
+
{% endblock %}
\ No newline at end of file
diff --git a/app/templates/auth/reset_password.html b/app/templates/auth/reset_password.html
new file mode 100644
index 0000000..61153f5
--- /dev/null
+++ b/app/templates/auth/reset_password.html
@@ -0,0 +1,23 @@
+{% extends "base.html" %}
+
+{% block app_content %}
+ Reset Your Password
+
+{% endblock %}
\ No newline at end of file
diff --git a/app/templates/auth/reset_password_request.html b/app/templates/auth/reset_password_request.html
new file mode 100644
index 0000000..14f56a0
--- /dev/null
+++ b/app/templates/auth/reset_password_request.html
@@ -0,0 +1,16 @@
+{% extends "base.html" %}
+
+{% block app_content %}
+ Reset Password
+
+{% endblock %}
\ No newline at end of file
diff --git a/app/templates/email/reset_password.html b/app/templates/email/reset_password.html
new file mode 100644
index 0000000..dced808
--- /dev/null
+++ b/app/templates/email/reset_password.html
@@ -0,0 +1,12 @@
+Dear User,
+
+ To reset your password
+
+ click here
+ .
+
+Alternatively, you can paste the following link in your browser's address bar:
+{{ url_for('auth.reset_password', token=token, _external=True) }}
+If you have not requested a password reset simply ignore this message.
+Sincerely,
+The Admins
\ No newline at end of file
diff --git a/app/templates/email/reset_password.txt b/app/templates/email/reset_password.txt
new file mode 100644
index 0000000..e35e60f
--- /dev/null
+++ b/app/templates/email/reset_password.txt
@@ -0,0 +1,11 @@
+Dear User,
+
+To reset your password click on the following link:
+
+{{ url_for('auth.reset_password', token=token, _external=True) }}
+
+If you have not requested a password reset simply ignore this message.
+
+Sincerely,
+
+The Admins
\ No newline at end of file
diff --git a/app/utils/email.py b/app/utils/email.py
new file mode 100644
index 0000000..2d50a80
--- /dev/null
+++ b/app/utils/email.py
@@ -0,0 +1,15 @@
+from app import mail
+from flask import current_app
+from flask_mail import Message
+from threading import Thread
+
+def send_async_email(app, msg):
+ with app.app_context():
+ mail.send(msg)
+
+def send_email(subject, sender, recipients, text_body, html_body):
+ msg = Message(subject, sender=sender, recipients=recipients)
+ msg.body = text_body
+ msg.html = html_body
+ Thread(target=send_async_email,
+ args=(current_app._get_current_object(), msg)).start()
\ No newline at end of file
diff --git a/configs/config.py b/configs/config.py
index 958b259..e47ad8e 100644
--- a/configs/config.py
+++ b/configs/config.py
@@ -13,3 +13,9 @@ class Config(object):
f"@{os.environ.get('DATABASE_HOST', 'localhost')}:{os.environ.get('DATABASE_PORT', '5432')}"
f"/{os.environ.get('DATABASE_DB', '') or os.environ.get('DATABASE_USER', 'scan2kasse')}")
SQLALCHEMY_TRACK_MODIFICATIONS = False
+ MAIL_SERVER = os.environ.get('MAIL_SERVER')
+ MAIL_PORT = int(os.environ.get('MAIL_PORT'))
+ MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') is not None
+ MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
+ MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
+ ADMINS = ['postmaster@wpgcommunity.net']
\ No newline at end of file