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

+
+ {{ form.hidden_tag() }} +

+ {{ form.password.label }}
+ {{ form.password(size=32) }}
+ {% for error in form.password.errors %} + [{{ error }}] + {% endfor %} +

+

+ {{ form.password2.label }}
+ {{ form.password2(size=32) }}
+ {% for error in form.password2.errors %} + [{{ error }}] + {% endfor %} +

+

{{ form.submit() }}

+
+{% 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

+
+ {{ form.hidden_tag() }} +

+ {{ form.email.label }}
+ {{ form.email(size=64) }}
+ {% for error in form.email.errors %} + [{{ error }}] + {% endfor %} +

+

{{ form.submit() }}

+
+{% 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