1
0
Fork 0
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:
Emma 2019-06-28 21:29:09 -06:00
parent 82c974fd8e
commit 195a007e85
10 changed files with 198 additions and 8 deletions

View file

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

View file

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

View file

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

View 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 %}

View file

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

View file

@ -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 %}

View file

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

View file

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