From be4eda66863d6e8b8dc31a4a7d507f3aa7daf1ae Mon Sep 17 00:00:00 2001 From: Bryan Bailey Date: Wed, 6 Apr 2022 01:24:06 -0400 Subject: [PATCH] authorization routes and logic --- app/__init__.py | 6 ++++ app/auth/__init__.py | 7 ++++ app/auth/forms.py | 29 ++++++++++++++++ app/auth/views.py | 50 ++++++++++++++++++++++++++ app/models.py | 52 ++++++++++++++++++++++++++-- app/templates/auth/login.html | 19 ++++++++++ app/templates/base.html | 12 +++++++ app/templates/index.html | 6 +++- requirements.txt | 2 +- {yoink/tests => tests}/test_basic.py | 0 tests/test_model_user.py | 26 ++++++++++++++ tests/test_web.py | 2 +- yoink/tests/__init__.py | 0 13 files changed, 205 insertions(+), 6 deletions(-) create mode 100644 app/auth/__init__.py create mode 100644 app/auth/forms.py create mode 100644 app/auth/views.py create mode 100644 app/templates/auth/login.html rename {yoink/tests => tests}/test_basic.py (100%) create mode 100644 tests/test_model_user.py delete mode 100644 yoink/tests/__init__.py diff --git a/app/__init__.py b/app/__init__.py index 5ff335f..ebb575d 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,5 +1,6 @@ from distutils.command.config import config from flask import Flask +from flask_login import LoginManager from flask_mail import Mail from flask_moment import Moment from flask_sqlalchemy import SQLAlchemy @@ -10,6 +11,8 @@ from config import configuration, DevelopmentConfig, TestingConfig, ProductionCo mail =Mail() moment = Moment() db = SQLAlchemy() +login_manager = LoginManager() +login_manager.login_view = 'auth.login' def create_app(config_name): @@ -21,9 +24,12 @@ def create_app(config_name): mail.init_app(app) moment.init_app(app) db.init_app(app) + login_manager.init_app(app) from .main import main as main_blueprint + from app.auth import auth as auth_blueprint app.register_blueprint(main_blueprint) + app.register_blueprint(auth_blueprint, url_prefix='/auth') return app \ No newline at end of file diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..fd01f5c --- /dev/null +++ b/app/auth/__init__.py @@ -0,0 +1,7 @@ +from flask import Blueprint + + +auth = Blueprint('auth', __name__) + + +from . import views \ No newline at end of file diff --git a/app/auth/forms.py b/app/auth/forms.py new file mode 100644 index 0000000..f73af46 --- /dev/null +++ b/app/auth/forms.py @@ -0,0 +1,29 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, BooleanField, SubmitField, ValidationError +from wtforms.validators import DataRequired, Length, Email, EqualTo, Regexp + +from app.models import User + + +class LoginForm(FlaskForm): + email = StringField('Email', validators=[DataRequired(), Length(1, 64), Email()]) + password = PasswordField('Password', validators=[DataRequired()]) + remember_me = BooleanField('Keep me logged in') + submit = SubmitField('Log In') + + +class RegistrationForm(FlaskForm): + email = StringField('Email', validators=[DataRequired(), Length(1, 64), Email()]) + username = StringField('Username', validators=[DataRequired(), Regexp('^[A-Za-z][A-Za-z0-9_.]*$', 0, 'Usernames must have only letters, numbers, dots or underscors')]) + password = PasswordField('Password', validators=[DataRequired(), EqualTo('password2', message='Passwords must match.')]) + password2 = PasswordField('Confirm Password', validators=[DataRequired()]) + submit = SubmitField('Register') + + + def validate_email(self, field): + if User.query.filter_by(email=field.data).first(): + raise ValidationError('Email already registered') + + def validate_username(self, field): + if User.query.filter_by(username=field.data).first(): + raise ValidationError('Username already in use') diff --git a/app/auth/views.py b/app/auth/views.py new file mode 100644 index 0000000..5afeb86 --- /dev/null +++ b/app/auth/views.py @@ -0,0 +1,50 @@ +from flask import render_template, redirect, request, url_for, flash +from flask_login import login_user, logout_user, login_required + +from app import db +from app.auth import auth +from app.models import User +from app.auth.forms import LoginForm, RegistrationForm + + + +@auth.route('/login', methods=['get', 'post']) +def login(): + form = LoginForm() + + if form.validate_on_submit(): + user = User.query.filter_by(email=form.email.data).first() + + if user is not None and user.verify_password(form.password.data): + login_user(user, form.remember_me.data) + + next = request.args.get('next') + + if next is None or next.startswith('/'): + next = url_for('main.index') + + return redirect(next) + flash('Invalid username or password', category='error') + return render_template('auth/login.html', form=form) + + +@auth.route('/logout') +@login_required +def logout(): + logout_user() + flash('You have been logged out.', category='info') + return redirect(url_for('main.index')) + + +@auth.route('/register', methods=['get', 'post']) +def register(): + form = RegistrationForm() + + if form.validate_on_submit(): + user = User(email=form.email.data, username=form.username.data, password=form.password.data) + db.session.add(user) + db.session.commit() + flash('You can now login', category='success') + return redirect(url_for('auth.login')) + + return render_template('auth/register.html', form=form) \ No newline at end of file diff --git a/app/models.py b/app/models.py index c3da8f4..d8efa04 100644 --- a/app/models.py +++ b/app/models.py @@ -1,4 +1,14 @@ -from app import db +from flask import current_app +from flask_login import UserMixin +from itsdangerous import TimedJSONWebSignatureSerializer +from werkzeug.security import generate_password_hash, check_password_hash + +from app import db, login_manager + + + +@login_manager.user_loader +def load_user(user_id): return User.query.get(int(user_id)) class Role(db.Model): @@ -11,14 +21,50 @@ class Role(db.Model): return f'' -class User(db.Model): +class User(UserMixin, db.Model): __tablename__ = 'users' id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(64), unique=True, index=True) username = db.Column(db.String(64), unique=True, index=True) role_id = db.Column(db.Integer, db.ForeignKey('roles.id')) + password_hash = db.Column(db.String(128)) + confirmed = db.Column(db.Boolean, default=False) def __repr__(self): return f'' + @property + def password(self): + raise AttributeError('password is not a readable attribute') + + @password.setter + def password(self, password): + self.password_hash = generate_password_hash(password, method='pbkdf2:sha256', salt_length=20) + + def verify_password(self, password): + return check_password_hash(self.password_hash, password) + + def generate_confirmation_token(self, expiration=3600): + s = TimedJSONWebSignatureSerializer(current_app, expiration) + return s.dumps({'confirm': self.id}).decode('utf-8') + + def confirm(self, token): + s = TimedJSONWebSignatureSerializer(current_app.config['SECRET_KEY']) + + try: + data = s.loads(token.encode('utf-8')) + except: + return False + + if data.get('confirm') != self.id: + return False + + self.confirmed = True + db.session.add(self) + db.session.commit() + + return True + + class ComicMeta(db.Model): __tablename__ = 'comicmeta' id = db.Column(db.Integer, primary_key=True) @@ -28,4 +74,4 @@ class ComicMeta(db.Model): previous_issue = db.Column(db.String(256), nullable=True) next_issue = db.Column(db.String(256), nullable=True) cover_path = db.Column(db.String(256)) - archive_path = db.Column(db.String(256)) \ No newline at end of file + archive_path = db.Column(db.String(256)) diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..ebbdc8f --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} + +{% block title %}Yo!nk - Login{% endblock %} + +{% block page_content %} + +
+
+ {{ form.hidden_tag() }} + {{ form.email.label }} {{ form.email() }} + {{ form.password.label }} {{ form.password() }} + {{ form.remember_me.label }} {{ form.remember_me() }} + {{ form.submit() }} +
+

New User? Click here to register a free account.

+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index 48cb4a8..d697777 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -30,6 +30,18 @@ Y!oink + + - +{% if current_user.is_authenticated %} +

{{ current_user.username }}'s Library

+{% else %} +

Comic Library

+{% endif %} {{ library.latest_downloads(latest) }} {% endblock %} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 319da4b..3020107 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ greenlet==1.1.2 idna==3.3 importlib-metadata==4.11.3 iniconfig==1.1.1 -itsdangerous==2.1.2 +itsdangerous==2.0.1 Jinja2==3.1.1 Mako==1.2.0 MarkupSafe==2.1.1 diff --git a/yoink/tests/test_basic.py b/tests/test_basic.py similarity index 100% rename from yoink/tests/test_basic.py rename to tests/test_basic.py diff --git a/tests/test_model_user.py b/tests/test_model_user.py new file mode 100644 index 0000000..2a79744 --- /dev/null +++ b/tests/test_model_user.py @@ -0,0 +1,26 @@ +import unittest + +from app.models import User + + + +class UserModelTestCase(unittest.TestCase): + + def test_password_setter(self): + u = User(password='abcdefg') + self.assertTrue(u.password_hash is not None) + + def test_no_password_getter(self): + u = User(password='abcdefg') + with self.assertRaises(AttributeError): + u.password + + def test_password_verification(self): + u = User(password='abcdefg') + self.assertTrue(u.verify_password('abcdefg')) + self.assertFalse(u.verify_password('password')) + + def test_password_salts_are_random(self): + u = User(password='abcdefg') + u2 = User(password='abcdefg') + self.assertTrue(u.password_hash != u2.password_hash) \ No newline at end of file diff --git a/tests/test_web.py b/tests/test_web.py index b7ccef3..145bff9 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -21,4 +21,4 @@ class BasicTestCase(unittest.TestCase): self.assertFalse(current_app is None) def test_app_is_testing(self): - self.assertTrue(current_app.config['TESTING']) \ No newline at end of file + self.assertTrue(current_app.config['TESTING']) diff --git a/yoink/tests/__init__.py b/yoink/tests/__init__.py deleted file mode 100644 index e69de29..0000000