From 68a017ab365f93215b5d0c75877d8a43a114dccd Mon Sep 17 00:00:00 2001 From: Adriaan Mulder Date: Thu, 13 Jul 2017 18:57:46 -0700 Subject: [PATCH 1/8] WIP forgot password. --- .env | 7 +++ app/forms/forgot_password.py | 12 +++++ app/forms/user.py | 2 +- app/libs/forgot_password.py | 35 ++++++++++++++ app/models/forgot_password_token.py | 45 ++++++++++++++++++ app/templates/forgot_password/forgot.html | 19 ++++++++ app/templates/forgot_password/reset.html | 19 ++++++++ app/views/__init__.py | 12 +++++ app/views/forgot_password.py | 57 +++++++++++++++++++++++ app/views/main.py | 6 +-- config/common.py | 5 ++ requirements.txt | 1 + 12 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 .env create mode 100644 app/forms/forgot_password.py create mode 100644 app/libs/forgot_password.py create mode 100644 app/models/forgot_password_token.py create mode 100644 app/templates/forgot_password/forgot.html create mode 100644 app/templates/forgot_password/reset.html create mode 100644 app/views/forgot_password.py diff --git a/.env b/.env new file mode 100644 index 0000000..879cbe2 --- /dev/null +++ b/.env @@ -0,0 +1,7 @@ +export S3_SECRET='pAkHn/dfHWXMdfKZM4wrPTzF3ccb1yw3AWUUUNl/' +export S3_KEY='AKIAJYBSDUSE3EUDGN7Q' +export S3_BUCKET='flask-starter' +export S3_UPLOAD_DIRECTORY='uploads' + +export ITSDANGEROUS_SECRET_KEY='7189fa0f2c8b4d3d93cee3a9eb57d40c' +export ITSDANGEROUS_SALT='saltsaltsalt' diff --git a/app/forms/forgot_password.py b/app/forms/forgot_password.py new file mode 100644 index 0000000..8f39f71 --- /dev/null +++ b/app/forms/forgot_password.py @@ -0,0 +1,12 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField +from wtforms.validators import DataRequired, Length, Email + + +class ForgotPasswordForm(FlaskForm): + email = StringField( + 'Email', validators=(DataRequired(), Email())) + + +class ResetPasswordForm(FlaskForm): + new_password = PasswordField('New Password') diff --git a/app/forms/user.py b/app/forms/user.py index 1266ebf..511bffc 100644 --- a/app/forms/user.py +++ b/app/forms/user.py @@ -7,7 +7,7 @@ class AuthForm(FlaskForm): email = StringField( 'Email', validators=(DataRequired(), Email())) password = PasswordField( - 'password', validators=(DataRequired(), Length(min=4))) + 'Password', validators=(DataRequired(), Length(min=4))) class UserForm(FlaskForm): diff --git a/app/libs/forgot_password.py b/app/libs/forgot_password.py new file mode 100644 index 0000000..5449174 --- /dev/null +++ b/app/libs/forgot_password.py @@ -0,0 +1,35 @@ +from itsdangerous import URLSafeTimedSerializer, BadSignature +import config +from uuid import uuid4 +from app.models.user import User +from app.models.forgot_password_token import ForgotPasswordToken + + +class InvalidTokenException(Exception): + pass + + +def decode(token): + '''Decode a token created with __generate_token''' + + [id, token] = token.split('_') + try: + return ForgotPasswordToken.validate_and_delete_token(id, token) + except ForgotPasswordToken.DoesNotExist: + raise InvalidTokenException('Token could not be decoded') + + +def forgot_password(email, sender): + '''Generate a forgot password token and pass it to sender function + The sender function should take one argument (the token) and it's + expected to send the token to the user with email 'email' via + email/sms/or other. + ''' + user = User.objects.get(email=email) + token_string = uuid4().hex + token = ForgotPasswordToken( + user=user, + token=token_string + ) + token.save() + sender('{}_{}'.format(token.id, token_string)) diff --git a/app/models/forgot_password_token.py b/app/models/forgot_password_token.py new file mode 100644 index 0000000..1081e47 --- /dev/null +++ b/app/models/forgot_password_token.py @@ -0,0 +1,45 @@ +from datetime import datetime, timedelta +import bcrypt + +import config +from . import db + + +class ForgotPasswordToken(db.Document): + user = db.ReferenceField('User') + token = db.StringField(required=True) + created_at = db.DateTimeField(default=datetime.utcnow) + + def save(self, *args, **kwargs): + self.__hash_token() + super(ForgotPasswordToken, self).save(*args, **kwargs) + + @staticmethod + def validate_and_delete_token(id, token_string): + '''Returns the user associated with token if valid''' + token = ForgotPasswordToken.objects.get(id=id) + token.delete() + if token.__verify_token(token_string): + return token.user + raise Token.DoesNotExist + + def __repr__(self): + return ''.format(self.id) + + def __verify_token(self, token): + if self.__is_expired(): + return False + return bcrypt.checkpw( + token.encode('utf-8'), + self.token.encode('utf-8')) + + def __hash_token(self): + self.token = bcrypt.hashpw( + self.token.encode('utf-8'), + bcrypt.gensalt() + ).decode('utf-8') + + def __is_expired(self): + delta = timedelta(seconds=config.FORGOT_PASSWORD_TIMER) + expiry = datetime.now() - delta + return expiry > self.created_at diff --git a/app/templates/forgot_password/forgot.html b/app/templates/forgot_password/forgot.html new file mode 100644 index 0000000..5dbbf15 --- /dev/null +++ b/app/templates/forgot_password/forgot.html @@ -0,0 +1,19 @@ +{% extends 'layout.html' %} +{% block title %}Forgot Password{% endblock %} +{% block content %} + +
+
+
+ {{ form.csrf_token }} +
+ {{ form.email(class="form-control", placeholder="Email") }} +
+
+ +
+
+
+
+ +{% endblock %} diff --git a/app/templates/forgot_password/reset.html b/app/templates/forgot_password/reset.html new file mode 100644 index 0000000..b421d42 --- /dev/null +++ b/app/templates/forgot_password/reset.html @@ -0,0 +1,19 @@ +{% extends 'layout.html' %} +{% block title %}Forgot Password{% endblock %} +{% block content %} + +
+
+
+ {{ form.csrf_token }} +
+ {{ form.new_password(class="form-control", placeholder="New Password") }} +
+
+ +
+
+
+
+ +{% endblock %} diff --git a/app/views/__init__.py b/app/views/__init__.py index e69de29..9eaff54 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -0,0 +1,12 @@ +# flake8: noqa +from flask import Blueprint + + +bp = None + + +if not bp: + bp = Blueprint('main', __name__) + + from . import main + from . import forgot_password diff --git a/app/views/forgot_password.py b/app/views/forgot_password.py new file mode 100644 index 0000000..c4b9924 --- /dev/null +++ b/app/views/forgot_password.py @@ -0,0 +1,57 @@ +from flask import flash, render_template, redirect, url_for +from app.models.user import User +from app.forms.forgot_password import ( + ForgotPasswordForm, + ResetPasswordForm +) +from app.libs.forgot_password import ( + forgot_password as forgot_password_util, + decode, + InvalidTokenException +) +from . import bp + +@bp.route('/forgot-password', methods=['GET', 'POST']) +def forgot_password(): + form = ForgotPasswordForm() + + if form.validate_on_submit(): + email = form.email.data + forgot_password_util( + email, + sender=__sender) + + return render_template( + 'forgot_password/forgot.html', + form=form + ) + + +@bp.route('/reset-password/', methods=['GET', 'POST']) +def reset_password(token): + form = ResetPasswordForm() + + if form.validate_on_submit(): + try: + user = decode(token) + password = form.new_password.data + user.update( + password=User.hash_password(password).decode('utf-8') + ) + flash('Password reset!', 'success') + except InvalidTokenException: + flash('The link is invalid or expired', 'error') + return redirect(url_for('main.index')) + + return render_template( + 'forgot_password/reset.html', + form=form, + token=token + ) + + +def __sender(token): + raise Exception( + 'Add a sender function to forgot_password_util call \n' + 'Token: {}'.format(token) + ) diff --git a/app/views/main.py b/app/views/main.py index 226c657..83c0709 100644 --- a/app/views/main.py +++ b/app/views/main.py @@ -1,9 +1,7 @@ from flask import Blueprint, render_template -from ..models.post import Post - - -bp = Blueprint('main', __name__) +from app.models.post import Post +from . import bp @bp.route('/') diff --git a/config/common.py b/config/common.py index 3292602..c17fc9c 100644 --- a/config/common.py +++ b/config/common.py @@ -1,3 +1,5 @@ +import os + # Disable Flask logger LOGGER_NAME = None LOGGER_HANDLER_POLICY = 'never' @@ -25,3 +27,6 @@ # View blueprints to be loaded, in the order specified # See `load_blueprints` in `app/__init__.py` for more details BLUEPRINTS = ('main', {'name': 'admin', 'url_prefix': '/admin'}) + +# forgot password +FORGOT_PASSWORD_TIMER = 3600 diff --git a/requirements.txt b/requirements.txt index b3afaae..1426dce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ flask-login flask-mongoengine flask-wtf gunicorn +itsdangerous From ebb91199f3bcee8996c94cc915fd7b33bf32a888 Mon Sep 17 00:00:00 2001 From: Adriaan Mulder Date: Fri, 14 Jul 2017 09:28:26 -0700 Subject: [PATCH 2/8] Fix lint errors. --- app/forms/forgot_password.py | 2 +- app/libs/forgot_password.py | 2 -- app/models/forgot_password_token.py | 2 +- app/views/forgot_password.py | 1 + app/views/main.py | 2 +- 5 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/forms/forgot_password.py b/app/forms/forgot_password.py index 8f39f71..3d3827d 100644 --- a/app/forms/forgot_password.py +++ b/app/forms/forgot_password.py @@ -1,6 +1,6 @@ from flask_wtf import FlaskForm from wtforms import StringField, PasswordField -from wtforms.validators import DataRequired, Length, Email +from wtforms.validators import DataRequired, Email class ForgotPasswordForm(FlaskForm): diff --git a/app/libs/forgot_password.py b/app/libs/forgot_password.py index 5449174..9918dc2 100644 --- a/app/libs/forgot_password.py +++ b/app/libs/forgot_password.py @@ -1,5 +1,3 @@ -from itsdangerous import URLSafeTimedSerializer, BadSignature -import config from uuid import uuid4 from app.models.user import User from app.models.forgot_password_token import ForgotPasswordToken diff --git a/app/models/forgot_password_token.py b/app/models/forgot_password_token.py index 1081e47..c240bb3 100644 --- a/app/models/forgot_password_token.py +++ b/app/models/forgot_password_token.py @@ -21,7 +21,7 @@ def validate_and_delete_token(id, token_string): token.delete() if token.__verify_token(token_string): return token.user - raise Token.DoesNotExist + raise ForgotPasswordToken.DoesNotExist def __repr__(self): return ''.format(self.id) diff --git a/app/views/forgot_password.py b/app/views/forgot_password.py index c4b9924..62c3358 100644 --- a/app/views/forgot_password.py +++ b/app/views/forgot_password.py @@ -11,6 +11,7 @@ ) from . import bp + @bp.route('/forgot-password', methods=['GET', 'POST']) def forgot_password(): form = ForgotPasswordForm() diff --git a/app/views/main.py b/app/views/main.py index 83c0709..52189b8 100644 --- a/app/views/main.py +++ b/app/views/main.py @@ -1,4 +1,4 @@ -from flask import Blueprint, render_template +from flask import render_template from app.models.post import Post from . import bp From 0fb7debe4e9bd4a2399b7896e65ec951e708c55e Mon Sep 17 00:00:00 2001 From: Adriaan Mulder Date: Fri, 14 Jul 2017 09:33:26 -0700 Subject: [PATCH 3/8] Remove itsdangerous from requirements. --- requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1426dce..b3afaae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,3 @@ flask-login flask-mongoengine flask-wtf gunicorn -itsdangerous From bccc6234aacb0c32bf0b4861830ad2ffa55d8cce Mon Sep 17 00:00:00 2001 From: Adriaan Mulder Date: Fri, 14 Jul 2017 09:34:14 -0700 Subject: [PATCH 4/8] Remove .env file from git. --- .env | 7 ------- .gitignore | 1 + 2 files changed, 1 insertion(+), 7 deletions(-) delete mode 100644 .env diff --git a/.env b/.env deleted file mode 100644 index 879cbe2..0000000 --- a/.env +++ /dev/null @@ -1,7 +0,0 @@ -export S3_SECRET='pAkHn/dfHWXMdfKZM4wrPTzF3ccb1yw3AWUUUNl/' -export S3_KEY='AKIAJYBSDUSE3EUDGN7Q' -export S3_BUCKET='flask-starter' -export S3_UPLOAD_DIRECTORY='uploads' - -export ITSDANGEROUS_SECRET_KEY='7189fa0f2c8b4d3d93cee3a9eb57d40c' -export ITSDANGEROUS_SALT='saltsaltsalt' diff --git a/.gitignore b/.gitignore index a7cedfe..fea08cf 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ *.db .DS_Store +.env env __pycache__ From f29543b8483e4a50f77fbd2eaa7eb9870e04b0a4 Mon Sep 17 00:00:00 2001 From: Adriaan Mulder Date: Fri, 14 Jul 2017 09:56:55 -0700 Subject: [PATCH 5/8] Add user as an argument to sender function. --- app/libs/forgot_password.py | 7 +++---- app/views/forgot_password.py | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/libs/forgot_password.py b/app/libs/forgot_password.py index 9918dc2..f51e269 100644 --- a/app/libs/forgot_password.py +++ b/app/libs/forgot_password.py @@ -8,8 +8,7 @@ class InvalidTokenException(Exception): def decode(token): - '''Decode a token created with __generate_token''' - + '''Decode a token generated in forgot_password''' [id, token] = token.split('_') try: return ForgotPasswordToken.validate_and_delete_token(id, token) @@ -19,7 +18,7 @@ def decode(token): def forgot_password(email, sender): '''Generate a forgot password token and pass it to sender function - The sender function should take one argument (the token) and it's + The sender function should take two argument (the token and the user) and it's expected to send the token to the user with email 'email' via email/sms/or other. ''' @@ -30,4 +29,4 @@ def forgot_password(email, sender): token=token_string ) token.save() - sender('{}_{}'.format(token.id, token_string)) + sender('{}_{}'.format(token.id, token_string), user) diff --git a/app/views/forgot_password.py b/app/views/forgot_password.py index 62c3358..d96fc6a 100644 --- a/app/views/forgot_password.py +++ b/app/views/forgot_password.py @@ -51,7 +51,7 @@ def reset_password(token): ) -def __sender(token): +def __sender(token, user): raise Exception( 'Add a sender function to forgot_password_util call \n' 'Token: {}'.format(token) From 9394420e61da05b1cb55739085803be486c4361f Mon Sep 17 00:00:00 2001 From: Adriaan Mulder Date: Fri, 14 Jul 2017 10:15:49 -0700 Subject: [PATCH 6/8] Fix lint errors. --- app/libs/forgot_password.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/libs/forgot_password.py b/app/libs/forgot_password.py index f51e269..e292f61 100644 --- a/app/libs/forgot_password.py +++ b/app/libs/forgot_password.py @@ -18,8 +18,8 @@ def decode(token): def forgot_password(email, sender): '''Generate a forgot password token and pass it to sender function - The sender function should take two argument (the token and the user) and it's - expected to send the token to the user with email 'email' via + The sender function should take two argument (the token and the user) + and it's expected to send the token to the user with email 'email' via email/sms/or other. ''' user = User.objects.get(email=email) From e14bf3b800d5df0d3b7fc8fc5e8ac2fa587671bc Mon Sep 17 00:00:00 2001 From: Adriaan Mulder Date: Fri, 14 Jul 2017 11:15:49 -0700 Subject: [PATCH 7/8] Move most forgot_password logic to it's own lib directory. --- app/__init__.py | 14 ++++++++++++-- .../__init__.py} | 2 +- .../forgot_password/forms.py} | 0 .../forgot_password/model.py} | 2 +- .../forgot_password/templates}/forgot.html | 2 +- .../forgot_password/templates}/reset.html | 2 +- .../forgot_password/views.py} | 19 +++++++++++++------ app/views/__init__.py | 1 - config/common.py | 6 +++++- 9 files changed, 34 insertions(+), 14 deletions(-) rename app/libs/{forgot_password.py => forgot_password/__init__.py} (93%) rename app/{forms/forgot_password.py => libs/forgot_password/forms.py} (100%) rename app/{models/forgot_password_token.py => libs/forgot_password/model.py} (98%) rename app/{templates/forgot_password => libs/forgot_password/templates}/forgot.html (85%) rename app/{templates/forgot_password => libs/forgot_password/templates}/reset.html (84%) rename app/{views/forgot_password.py => libs/forgot_password/views.py} (83%) diff --git a/app/__init__.py b/app/__init__.py index d0c1894..ef2620f 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -99,12 +99,17 @@ def load_blueprints(app): { name: 'advanced', url_prefix: '/advanced' + }, + { + name: 'forgot_password', + path: 'app.libs.forgot_password.views' } ) In the above example, the "basic" Blueprint will be loaded with no additional settings. The second example will load the "advanced" Blueprint - with the `url_prefix` set to "/advanced". + with the `url_prefix` set to "/advanced". The third example will + load the "forgot_password" Blueprint from `path` All `Blueprint` kwargs are supported in the advanced version. @@ -113,10 +118,15 @@ def load_blueprints(app): if isinstance(blueprint, str): name = blueprint kwargs = {} + path = None else: args = blueprint.copy() name = args.pop('name') + path = args.pop('path') if args.get('path') else None kwargs = args - view = importlib.import_module('app.views.{0}'.format(name)) + if not path: + view = importlib.import_module('app.views.{0}'.format(name)) + else: + view = importlib.import_module(path) app.register_blueprint(view.bp, **kwargs) diff --git a/app/libs/forgot_password.py b/app/libs/forgot_password/__init__.py similarity index 93% rename from app/libs/forgot_password.py rename to app/libs/forgot_password/__init__.py index e292f61..e9578cb 100644 --- a/app/libs/forgot_password.py +++ b/app/libs/forgot_password/__init__.py @@ -1,6 +1,6 @@ from uuid import uuid4 from app.models.user import User -from app.models.forgot_password_token import ForgotPasswordToken +from .model import ForgotPasswordToken class InvalidTokenException(Exception): diff --git a/app/forms/forgot_password.py b/app/libs/forgot_password/forms.py similarity index 100% rename from app/forms/forgot_password.py rename to app/libs/forgot_password/forms.py diff --git a/app/models/forgot_password_token.py b/app/libs/forgot_password/model.py similarity index 98% rename from app/models/forgot_password_token.py rename to app/libs/forgot_password/model.py index c240bb3..441d701 100644 --- a/app/models/forgot_password_token.py +++ b/app/libs/forgot_password/model.py @@ -1,8 +1,8 @@ from datetime import datetime, timedelta import bcrypt +from app.models import db import config -from . import db class ForgotPasswordToken(db.Document): diff --git a/app/templates/forgot_password/forgot.html b/app/libs/forgot_password/templates/forgot.html similarity index 85% rename from app/templates/forgot_password/forgot.html rename to app/libs/forgot_password/templates/forgot.html index 5dbbf15..d9cfe86 100644 --- a/app/templates/forgot_password/forgot.html +++ b/app/libs/forgot_password/templates/forgot.html @@ -4,7 +4,7 @@
-
+ {{ form.csrf_token }}
{{ form.email(class="form-control", placeholder="Email") }} diff --git a/app/templates/forgot_password/reset.html b/app/libs/forgot_password/templates/reset.html similarity index 84% rename from app/templates/forgot_password/reset.html rename to app/libs/forgot_password/templates/reset.html index b421d42..9d9cbd4 100644 --- a/app/templates/forgot_password/reset.html +++ b/app/libs/forgot_password/templates/reset.html @@ -4,7 +4,7 @@
- + {{ form.csrf_token }}
{{ form.new_password(class="form-control", placeholder="New Password") }} diff --git a/app/views/forgot_password.py b/app/libs/forgot_password/views.py similarity index 83% rename from app/views/forgot_password.py rename to app/libs/forgot_password/views.py index d96fc6a..86d99a0 100644 --- a/app/views/forgot_password.py +++ b/app/libs/forgot_password/views.py @@ -1,15 +1,22 @@ -from flask import flash, render_template, redirect, url_for +from flask import ( + Blueprint, + flash, + render_template, + redirect, + url_for +) from app.models.user import User -from app.forms.forgot_password import ( +from .forms import ( ForgotPasswordForm, ResetPasswordForm ) -from app.libs.forgot_password import ( +from . import ( forgot_password as forgot_password_util, decode, InvalidTokenException ) -from . import bp + +bp = Blueprint('forgot_password', __name__, template_folder='templates') @bp.route('/forgot-password', methods=['GET', 'POST']) @@ -23,7 +30,7 @@ def forgot_password(): sender=__sender) return render_template( - 'forgot_password/forgot.html', + 'forgot.html', form=form ) @@ -45,7 +52,7 @@ def reset_password(token): return redirect(url_for('main.index')) return render_template( - 'forgot_password/reset.html', + 'reset.html', form=form, token=token ) diff --git a/app/views/__init__.py b/app/views/__init__.py index 9eaff54..94b223a 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -9,4 +9,3 @@ bp = Blueprint('main', __name__) from . import main - from . import forgot_password diff --git a/config/common.py b/config/common.py index c17fc9c..cb2069e 100644 --- a/config/common.py +++ b/config/common.py @@ -26,7 +26,11 @@ # View blueprints to be loaded, in the order specified # See `load_blueprints` in `app/__init__.py` for more details -BLUEPRINTS = ('main', {'name': 'admin', 'url_prefix': '/admin'}) +BLUEPRINTS = ( + 'main', + {'name': 'forgot_password', 'path': 'app.libs.forgot_password.views'}, + {'name': 'admin', 'url_prefix': '/admin'} +) # forgot password FORGOT_PASSWORD_TIMER = 3600 From 2f92aa5c39d74d87376bd6dccbf06869eb5f28c6 Mon Sep 17 00:00:00 2001 From: Adriaan Mulder Date: Fri, 14 Jul 2017 11:19:08 -0700 Subject: [PATCH 8/8] Fix lint errors. --- app/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/__init__.py b/app/__init__.py index ef2620f..c957f9a 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -118,7 +118,7 @@ def load_blueprints(app): if isinstance(blueprint, str): name = blueprint kwargs = {} - path = None + path = None else: args = blueprint.copy() name = args.pop('name')