authorization routes and logic

This commit is contained in:
Bryan Bailey
2022-04-06 01:24:06 -04:00
parent 5ca91452ca
commit be4eda6686
13 changed files with 205 additions and 6 deletions

View File

@@ -1,5 +1,6 @@
from distutils.command.config import config from distutils.command.config import config
from flask import Flask from flask import Flask
from flask_login import LoginManager
from flask_mail import Mail from flask_mail import Mail
from flask_moment import Moment from flask_moment import Moment
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
@@ -10,6 +11,8 @@ from config import configuration, DevelopmentConfig, TestingConfig, ProductionCo
mail =Mail() mail =Mail()
moment = Moment() moment = Moment()
db = SQLAlchemy() db = SQLAlchemy()
login_manager = LoginManager()
login_manager.login_view = 'auth.login'
def create_app(config_name): def create_app(config_name):
@@ -21,9 +24,12 @@ def create_app(config_name):
mail.init_app(app) mail.init_app(app)
moment.init_app(app) moment.init_app(app)
db.init_app(app) db.init_app(app)
login_manager.init_app(app)
from .main import main as main_blueprint from .main import main as main_blueprint
from app.auth import auth as auth_blueprint
app.register_blueprint(main_blueprint) app.register_blueprint(main_blueprint)
app.register_blueprint(auth_blueprint, url_prefix='/auth')
return app return app

7
app/auth/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
from flask import Blueprint
auth = Blueprint('auth', __name__)
from . import views

29
app/auth/forms.py Normal file
View File

@@ -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')

50
app/auth/views.py Normal file
View File

@@ -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)

View File

@@ -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): class Role(db.Model):
@@ -11,14 +21,50 @@ class Role(db.Model):
return f'<Role {self.name!r}>' return f'<Role {self.name!r}>'
class User(db.Model): class User(UserMixin, db.Model):
__tablename__ = 'users' __tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True) 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) username = db.Column(db.String(64), unique=True, index=True)
role_id = db.Column(db.Integer, db.ForeignKey('roles.id')) 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'<User {self.username!r}>' def __repr__(self): return f'<User {self.username!r}>'
@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): class ComicMeta(db.Model):
__tablename__ = 'comicmeta' __tablename__ = 'comicmeta'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)

View File

@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block title %}Yo!nk - Login{% endblock %}
{% block page_content %}
<div class="page-header">
<h1>Login</h1>
</div>
<div class="col-md-4">
<form method="post">
{{ form.hidden_tag() }}
{{ form.email.label }} {{ form.email() }}
{{ form.password.label }} {{ form.password() }}
{{ form.remember_me.label }} {{ form.remember_me() }}
{{ form.submit() }}
</form>
<p>New User? Click <a href="{{ url_for('auth.register') }}">here</a> to register a free account.</p>
</div>
{% endblock %}

View File

@@ -30,6 +30,18 @@
</button> </button>
<a href="/" class="navbar-brand">Y!oink</a> <a href="/" class="navbar-brand">Y!oink</a>
<ul class="nav navbar-nav navbar-right">
{% if current_user.is_authenticated %}
<li>
<a href="{{ url_for('auth.logout') }}">Log Out</a>
</li>
{% else %}
<li>
<a href="{{ url_for('auth.login') }}">Log In</a>
</li>
{% endif %}
</ul>
</div> </div>
<div class="navbar-collapse collapse"> <div class="navbar-collapse collapse">

View File

@@ -25,7 +25,11 @@
{{ form.download(class_='btn btn-outline-primary') }} {{ form.download(class_='btn btn-outline-primary') }}
</div> </div>
</form> </form>
{% if current_user.is_authenticated %}
<h2>{{ current_user.username }}'s Library</h2>
{% else %}
<h2>Comic Library</h2>
{% endif %}
{{ library.latest_downloads(latest) }} {{ library.latest_downloads(latest) }}
{% endblock %} {% endblock %}

View File

@@ -21,7 +21,7 @@ greenlet==1.1.2
idna==3.3 idna==3.3
importlib-metadata==4.11.3 importlib-metadata==4.11.3
iniconfig==1.1.1 iniconfig==1.1.1
itsdangerous==2.1.2 itsdangerous==2.0.1
Jinja2==3.1.1 Jinja2==3.1.1
Mako==1.2.0 Mako==1.2.0
MarkupSafe==2.1.1 MarkupSafe==2.1.1

26
tests/test_model_user.py Normal file
View File

@@ -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)