authorization routes and logic
This commit is contained in:
@@ -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
7
app/auth/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
|
||||||
|
auth = Blueprint('auth', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
from . import views
|
||||||
29
app/auth/forms.py
Normal file
29
app/auth/forms.py
Normal 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
50
app/auth/views.py
Normal 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)
|
||||||
@@ -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)
|
||||||
@@ -28,4 +74,4 @@ class ComicMeta(db.Model):
|
|||||||
previous_issue = db.Column(db.String(256), nullable=True)
|
previous_issue = db.Column(db.String(256), nullable=True)
|
||||||
next_issue = db.Column(db.String(256), nullable=True)
|
next_issue = db.Column(db.String(256), nullable=True)
|
||||||
cover_path = db.Column(db.String(256))
|
cover_path = db.Column(db.String(256))
|
||||||
archive_path = db.Column(db.String(256))
|
archive_path = db.Column(db.String(256))
|
||||||
|
|||||||
19
app/templates/auth/login.html
Normal file
19
app/templates/auth/login.html
Normal 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 %}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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
26
tests/test_model_user.py
Normal 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)
|
||||||
@@ -21,4 +21,4 @@ class BasicTestCase(unittest.TestCase):
|
|||||||
self.assertFalse(current_app is None)
|
self.assertFalse(current_app is None)
|
||||||
|
|
||||||
def test_app_is_testing(self):
|
def test_app_is_testing(self):
|
||||||
self.assertTrue(current_app.config['TESTING'])
|
self.assertTrue(current_app.config['TESTING'])
|
||||||
|
|||||||
Reference in New Issue
Block a user