From 3af67fee01d1c1833e7e64a83472ae35dc37b7cf Mon Sep 17 00:00:00 2001 From: Santiago Lamora Date: Tue, 28 Dec 2021 09:39:12 +0100 Subject: [PATCH] Implement user login based on sessions Use Flask-Login & Flask-WTF libraries --- ereuse_devicehub/forms.py | 62 +++++++++++++++++++ ereuse_devicehub/resources/user/models.py | 13 +++- .../ereuse_devicehub/user_login.html | 16 +++-- ereuse_devicehub/utils.py | 8 +++ ereuse_devicehub/views.py | 27 +++++++- requirements.txt | 2 + 6 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 ereuse_devicehub/forms.py create mode 100644 ereuse_devicehub/utils.py diff --git a/ereuse_devicehub/forms.py b/ereuse_devicehub/forms.py new file mode 100644 index 00000000..5c2b8d15 --- /dev/null +++ b/ereuse_devicehub/forms.py @@ -0,0 +1,62 @@ +from flask_wtf import FlaskForm +from werkzeug.security import generate_password_hash +from wtforms import EmailField, PasswordField, validators + +from ereuse_devicehub.resources.user.models import User + + +class LoginForm(FlaskForm): + email = EmailField('Email Address', [validators.Length(min=6, max=35)]) + password = PasswordField('Password', [ + validators.DataRequired(), + ]) + + error_messages = { + 'invalid_login': ( + "Please enter a correct email and password. Note that both " + "fields may be case-sensitive." + ), + 'inactive': "This account is inactive.", + } + + def validate(self, extra_validators=None): + is_valid = super().validate(extra_validators) + + if not is_valid: + return False + + email = self.email.data + password = self.password.data + self.user_cache = self.authenticate(email, password) + + if self.user_cache is None: + self.form_errors.append(self.error_messages['invalid_login']) + return False + + return self.confirm_login_allowed(self.user_cache) + + def authenticate(self, email, password): + if email is None or password is None: + return + user = User.query.filter_by(email=email).first() + if user is None: + # Run the default password hasher once to reduce the timing + # difference between an existing and a nonexistent user (#20760). + generate_password_hash(password) + else: + if user.check_password(password): + return user + + def confirm_login_allowed(self, user): + """ + Controls whether the given User may log in. This is a policy setting, + independent of end-user authentication. This default behavior is to + allow login by active users, and reject login by inactive users. + If the given user cannot log in, this method should raise a + ``ValidationError``. + If the given user may log in, this method should return None. + """ + if not user.is_active: + self.form_errors.append(self.error_messages['inactive']) + + return user.is_active diff --git a/ereuse_devicehub/resources/user/models.py b/ereuse_devicehub/resources/user/models.py index 79525286..c9d3fef7 100644 --- a/ereuse_devicehub/resources/user/models.py +++ b/ereuse_devicehub/resources/user/models.py @@ -1,7 +1,7 @@ from uuid import uuid4 -from citext import CIText from flask import current_app as app +from flask_login import UserMixin from sqlalchemy import Column, Boolean, BigInteger, Sequence from sqlalchemy.dialects.postgresql import UUID from sqlalchemy_utils import EmailType, PasswordType @@ -13,7 +13,7 @@ from ereuse_devicehub.resources.models import STR_SIZE, Thing from ereuse_devicehub.resources.enums import SessionType -class User(Thing): +class User(UserMixin, Thing): __table_args__ = {'schema': 'common'} id = Column(UUID(as_uuid=True), default=uuid4, primary_key=True) email = Column(EmailType, nullable=False, unique=True) @@ -66,6 +66,15 @@ class User(Thing): return return self.email.split('@')[0].split('_')[1] + @property + def is_active(self): + """Alias because flask-login expects `is_active` attribute""" + return self.active + + def check_password(self, password): + # take advantage of SQL Alchemy PasswordType to verify password + return self.password == password + class UserInventory(db.Model): """Relationship between users and their inventories.""" diff --git a/ereuse_devicehub/templates/ereuse_devicehub/user_login.html b/ereuse_devicehub/templates/ereuse_devicehub/user_login.html index 5d86efed..b8696521 100644 --- a/ereuse_devicehub/templates/ereuse_devicehub/user_login.html +++ b/ereuse_devicehub/templates/ereuse_devicehub/user_login.html @@ -22,22 +22,30 @@
Login to Your Account

Enter your username & password to login

+ {% if form.form_errors %} +

+ {% for error in form.form_errors %} + {{ error }}
+ {% endfor %} +

+ {% endif %}
-
+ + {{ form.csrf_token }}
- +
@ - +
Please enter your username.
- +
Please enter your password!
diff --git a/ereuse_devicehub/utils.py b/ereuse_devicehub/utils.py new file mode 100644 index 00000000..d4f60cce --- /dev/null +++ b/ereuse_devicehub/utils.py @@ -0,0 +1,8 @@ +from urllib.parse import urljoin, urlparse + + +def is_safe_url(request, target): + ref_url = urlparse(request.host_url) + test_url = urlparse(urljoin(request.host_url, target)) + return test_url.scheme in ('http', 'https') and \ + ref_url.netloc == test_url.netloc diff --git a/ereuse_devicehub/views.py b/ereuse_devicehub/views.py index 9e9f795d..15e7d3e5 100644 --- a/ereuse_devicehub/views.py +++ b/ereuse_devicehub/views.py @@ -1,22 +1,43 @@ -from flask import Blueprint, render_template +import flask +from flask import Blueprint from flask.views import View +from flask_login import login_user +from ereuse_devicehub.forms import LoginForm +from ereuse_devicehub.resources.user.models import User +from ereuse_devicehub.utils import is_safe_url core = Blueprint('core', __name__) class LoginView(View): + methods = ['GET', 'POST'] template_name = 'ereuse_devicehub/user_login.html' def dispatch_request(self): - return render_template(self.template_name) + form = LoginForm() + if form.validate_on_submit(): + # Login and validate the user. + # user should be an instance of your `User` class + user = User.query.filter_by(email=form.email.data).first() + login_user(user) + + next_url = flask.request.args.get('next') + # is_safe_url should check if the url is safe for redirects. + # See http://flask.pocoo.org/snippets/62/ for an example. + if not is_safe_url(flask.request, next_url): + return flask.abort(400) + + return flask.redirect(next_url or flask.url_for('core.user-profile')) + return flask.render_template('ereuse_devicehub/user_login.html', form=form) class UserProfileView(View): template_name = 'ereuse_devicehub/user_profile.html' def dispatch_request(self): - return render_template(self.template_name) + context = {} + return flask.render_template(self.template_name, **context) core.add_url_rule('/login/', view_func=LoginView.as_view('login')) diff --git a/requirements.txt b/requirements.txt index bd7e7980..e5b35869 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,9 @@ colour==0.1.5 ereuse-utils[naming,test,session,cli]==0.4.0b49 Flask==1.0.2 Flask-Cors==3.0.6 +Flask-Login==0.5.0 Flask-SQLAlchemy==2.3.2 +Flask-WTF==1.0.0 hashids==1.2.0 inflection==0.3.1 marshmallow==3.0.0b11