mirror of
https://github.com/shouptech/humulus.git
synced 2026-02-03 16:09:44 +00:00
Add login capability
This commit is contained in:
parent
82c974fd8e
commit
195a007e85
10 changed files with 198 additions and 8 deletions
|
|
@ -42,4 +42,8 @@ def create_app(test_config=None):
|
||||||
from . import recipes
|
from . import recipes
|
||||||
app.register_blueprint(recipes.bp)
|
app.register_blueprint(recipes.bp)
|
||||||
|
|
||||||
|
# Register auth blueprint
|
||||||
|
from . import auth
|
||||||
|
app.register_blueprint(auth.bp)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
|
||||||
63
src/humulus/auth.py
Normal file
63
src/humulus/auth.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
"""This module handles routes for authentication."""
|
||||||
|
|
||||||
|
# Copyright 2019 Mike Shoup
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
import functools
|
||||||
|
|
||||||
|
from flask import (Blueprint, current_app, flash, redirect, render_template,
|
||||||
|
session, url_for)
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import(PasswordField)
|
||||||
|
from wtforms.validators import DataRequired
|
||||||
|
|
||||||
|
|
||||||
|
bp = Blueprint('auth', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginForm(FlaskForm):
|
||||||
|
"""Form for login."""
|
||||||
|
password = PasswordField('Password', validators=[DataRequired()])
|
||||||
|
|
||||||
|
|
||||||
|
def login_required(view):
|
||||||
|
"""View decorator that redirects anonymous users to the login page."""
|
||||||
|
@functools.wraps(view)
|
||||||
|
def wrapped_view(**kwargs):
|
||||||
|
logged_in = session.get('logged_in', False)
|
||||||
|
if not logged_in:
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
return view(**kwargs)
|
||||||
|
|
||||||
|
return wrapped_view
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/login', methods=('GET', 'POST'))
|
||||||
|
def login():
|
||||||
|
form = LoginForm()
|
||||||
|
|
||||||
|
if form.validate_on_submit():
|
||||||
|
if form.password.data == current_app.config['HUMULUS_PASSWORD']:
|
||||||
|
session.clear()
|
||||||
|
session['logged_in'] = True
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
flash('Password is invalid.', category='warning')
|
||||||
|
return render_template('auth/login.html', form=form)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/logout')
|
||||||
|
def logout():
|
||||||
|
session.clear()
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
@ -22,6 +22,7 @@ from wtforms import (Form, StringField, DecimalField, TextAreaField, FieldList,
|
||||||
FormField, SelectField)
|
FormField, SelectField)
|
||||||
from wtforms.validators import DataRequired, Optional
|
from wtforms.validators import DataRequired, Optional
|
||||||
|
|
||||||
|
from humulus.auth import login_required
|
||||||
from humulus.couch import get_doc_or_404, put_doc, update_doc, get_view
|
from humulus.couch import get_doc_or_404, put_doc, update_doc, get_view
|
||||||
|
|
||||||
bp = Blueprint('recipes', __name__, url_prefix='/recipes')
|
bp = Blueprint('recipes', __name__, url_prefix='/recipes')
|
||||||
|
|
@ -206,6 +207,7 @@ def index():
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/create', methods=('GET', 'POST'))
|
@bp.route('/create', methods=('GET', 'POST'))
|
||||||
|
@login_required
|
||||||
def create():
|
def create():
|
||||||
form = RecipeForm()
|
form = RecipeForm()
|
||||||
|
|
||||||
|
|
@ -222,6 +224,7 @@ def info(id):
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/delete/<id>', methods=('POST',))
|
@bp.route('/delete/<id>', methods=('POST',))
|
||||||
|
@login_required
|
||||||
def delete(id):
|
def delete(id):
|
||||||
recipe = get_doc_or_404(id)
|
recipe = get_doc_or_404(id)
|
||||||
recipe.delete()
|
recipe.delete()
|
||||||
|
|
@ -229,6 +232,7 @@ def delete(id):
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/update/<id>', methods=('GET', 'POST'))
|
@bp.route('/update/<id>', methods=('GET', 'POST'))
|
||||||
|
@login_required
|
||||||
def update(id):
|
def update(id):
|
||||||
# Get the recipe from the database and validate it is the same revision
|
# Get the recipe from the database and validate it is the same revision
|
||||||
form = RecipeForm()
|
form = RecipeForm()
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,11 @@
|
||||||
</ul>
|
</ul>
|
||||||
<ul class="navbar-nav ml-auto">
|
<ul class="navbar-nav ml-auto">
|
||||||
<li class="nav-item active">
|
<li class="nav-item active">
|
||||||
<a class="nav-link" href="#">Login</a>
|
{% if session.logged_in %}
|
||||||
|
<a class="nav-link" href="{{ url_for('auth.logout') }}">Logout</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="nav-link" href="{{ url_for('auth.login') }}">Login</a>
|
||||||
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
30
src/humulus/templates/auth/login.html
Normal file
30
src/humulus/templates/auth/login.html
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{#-
|
||||||
|
Copyright 2019 Mike Shoup
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
-#}
|
||||||
|
{% extends '_base.html' %}
|
||||||
|
{% from "_macros.html" import render_field_with_errors %}
|
||||||
|
{% block title %}Login{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<form method="POST" action_url="{{ url_for('auth.login') }}">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
<div class="row">
|
||||||
|
{{ render_field_with_errors(form.password) }}
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<button type="submit" class="btn btn-primary">Login</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -21,9 +21,11 @@
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="row"><h1>Recipes</h1></div>
|
<div class="row"><h1>Recipes</h1></div>
|
||||||
|
{% if session.logged_in %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<a href="{{ url_for('recipes.create') }}" class="btn btn-primary btn-sm mt-1 mb-2 ml-auto">Create a recipe</a>
|
<a href="{{ url_for('recipes.create') }}" class="btn btn-primary btn-sm mt-1 mb-2 ml-auto">Create a recipe</a>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<table class="table table-hover table-sm">
|
<table class="table table-hover table-sm">
|
||||||
<thead>
|
<thead>
|
||||||
|
|
|
||||||
|
|
@ -143,10 +143,12 @@
|
||||||
{#-
|
{#-
|
||||||
Buttons to do things
|
Buttons to do things
|
||||||
-#}
|
-#}
|
||||||
|
{% if session.logged_in %}
|
||||||
<div class="row mt-4 pt-1 border-top">
|
<div class="row mt-4 pt-1 border-top">
|
||||||
<a class="btn btn-secondary mr-1" href="{{ url_for('recipes.update', id=recipe._id) }}">Update Recipe</a>
|
<a class="btn btn-secondary mr-1" href="{{ url_for('recipes.update', id=recipe._id) }}">Update Recipe</a>
|
||||||
{{ render_delete_button('Delete Recipe', 'deleteRecipe', 'btn-danger') }}
|
{{ render_delete_button('Delete Recipe', 'deleteRecipe', 'btn-danger') }}
|
||||||
</div>
|
</div>
|
||||||
{{ render_delete_modal(url_for('recipes.delete', id=recipe._id), 'deleteRecipe', recipe.name) }}
|
{{ render_delete_modal(url_for('recipes.delete', id=recipe._id), 'deleteRecipe', recipe.name) }}
|
||||||
|
{% endif %}
|
||||||
<div class="row small mt-4">Recipe revision: {{ recipe._rev }}</div>
|
<div class="row small mt-4">Recipe revision: {{ recipe._rev }}</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,8 @@ def app():
|
||||||
'COUCH_PASSWORD': 'password',
|
'COUCH_PASSWORD': 'password',
|
||||||
'COUCH_DATABASE': dbname,
|
'COUCH_DATABASE': dbname,
|
||||||
'WTF_CSRF_ENABLED': False,
|
'WTF_CSRF_ENABLED': False,
|
||||||
'SECRET_KEY': 'testing'
|
'SECRET_KEY': 'testing',
|
||||||
|
'HUMULUS_PASSWORD': 'password'
|
||||||
})
|
})
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
|
|
@ -132,3 +133,22 @@ def runner(app):
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client(app):
|
def client(app):
|
||||||
return app.test_client()
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
class AuthActions(object):
|
||||||
|
def __init__(self, client):
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
def login(self, password='password'):
|
||||||
|
return self._client.post(
|
||||||
|
'/login',
|
||||||
|
data={'password': password}
|
||||||
|
)
|
||||||
|
|
||||||
|
def logout(self):
|
||||||
|
return self._client.get('/logout')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth(client):
|
||||||
|
return AuthActions(client)
|
||||||
|
|
|
||||||
46
tests/test_auth.py
Normal file
46
tests/test_auth.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# Copyright 2019 Mike Shoup
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
from flask import session
|
||||||
|
|
||||||
|
|
||||||
|
def test_login(client, auth):
|
||||||
|
# Test GET
|
||||||
|
response = client.get('/login')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Test failed login
|
||||||
|
data = {'password': 'invalid'}
|
||||||
|
response = client.post('/login', data=data)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b'Password is invalid' in response.data
|
||||||
|
|
||||||
|
# Test successful login
|
||||||
|
data = {'password': 'password'}
|
||||||
|
response = client.post('/login', data=data)
|
||||||
|
assert response.status_code == 302
|
||||||
|
with client.session_transaction() as session:
|
||||||
|
assert session['logged_in']
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout(client, auth):
|
||||||
|
# Login
|
||||||
|
auth.login()
|
||||||
|
with client.session_transaction() as session:
|
||||||
|
assert session['logged_in']
|
||||||
|
|
||||||
|
response = client.get('/logout')
|
||||||
|
assert response.status_code == 302
|
||||||
|
with client.session_transaction() as session:
|
||||||
|
assert not session.get('logged_in', False)
|
||||||
|
|
@ -70,9 +70,14 @@ def test_index(client):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_create(client, app):
|
def test_create(client, app, auth):
|
||||||
"""Test success in creating a recipe document."""
|
"""Test success in creating a recipe document."""
|
||||||
# Test GET
|
# Test GET without login
|
||||||
|
response = client.get('/recipes/create')
|
||||||
|
assert response.status_code == 302
|
||||||
|
|
||||||
|
# Test GET with login
|
||||||
|
auth.login()
|
||||||
response = client.get('/recipes/create')
|
response = client.get('/recipes/create')
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
@ -95,8 +100,13 @@ def test_create(client, app):
|
||||||
assert doc['efficiency'] == '65'
|
assert doc['efficiency'] == '65'
|
||||||
|
|
||||||
|
|
||||||
def test_update(client, app):
|
def test_update(client, app, auth):
|
||||||
"""Test success in updating a recipe document."""
|
"""Test success in updating a recipe document."""
|
||||||
|
# Test GET without login
|
||||||
|
response = client.get('/recipes/update/awesome-lager')
|
||||||
|
assert response.status_code == 302
|
||||||
|
|
||||||
|
auth.login()
|
||||||
# Test GET on a bare minimum recipe
|
# Test GET on a bare minimum recipe
|
||||||
response = client.get('/recipes/update/awesome-lager')
|
response = client.get('/recipes/update/awesome-lager')
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
@ -268,11 +278,16 @@ def test_recipe_form_doc(app):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_recipe_delete(client):
|
def test_recipe_delete(client, auth):
|
||||||
"""Test success in deleting a document."""
|
"""Test success in deleting a document."""
|
||||||
|
# Try to delete a document without logging in
|
||||||
response = client.post('/recipes/delete/awesome-lager')
|
response = client.post('/recipes/delete/awesome-lager')
|
||||||
assert response.status_code == 302
|
response = client.get('/recipes/info/awesome-lager')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Try to get the doc now
|
# Delete document after login
|
||||||
|
auth.login()
|
||||||
|
# Try to delete a document without logging in
|
||||||
|
response = client.post('/recipes/delete/awesome-lager')
|
||||||
response = client.get('/recipes/info/awesome-lager')
|
response = client.get('/recipes/info/awesome-lager')
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue