diff --git a/src/humulus/app.py b/src/humulus/app.py index 7af6270..171b6ba 100644 --- a/src/humulus/app.py +++ b/src/humulus/app.py @@ -42,4 +42,8 @@ def create_app(test_config=None): from . import recipes app.register_blueprint(recipes.bp) + # Register auth blueprint + from . import auth + app.register_blueprint(auth.bp) + return app diff --git a/src/humulus/auth.py b/src/humulus/auth.py new file mode 100644 index 0000000..5cbe2be --- /dev/null +++ b/src/humulus/auth.py @@ -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')) diff --git a/src/humulus/recipes.py b/src/humulus/recipes.py index 5c10daf..97d0978 100644 --- a/src/humulus/recipes.py +++ b/src/humulus/recipes.py @@ -22,6 +22,7 @@ from wtforms import (Form, StringField, DecimalField, TextAreaField, FieldList, FormField, SelectField) 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 bp = Blueprint('recipes', __name__, url_prefix='/recipes') @@ -206,6 +207,7 @@ def index(): @bp.route('/create', methods=('GET', 'POST')) +@login_required def create(): form = RecipeForm() @@ -222,6 +224,7 @@ def info(id): @bp.route('/delete/', methods=('POST',)) +@login_required def delete(id): recipe = get_doc_or_404(id) recipe.delete() @@ -229,6 +232,7 @@ def delete(id): @bp.route('/update/', methods=('GET', 'POST')) +@login_required def update(id): # Get the recipe from the database and validate it is the same revision form = RecipeForm() diff --git a/src/humulus/templates/_base.html b/src/humulus/templates/_base.html index 8e94dff..3421471 100644 --- a/src/humulus/templates/_base.html +++ b/src/humulus/templates/_base.html @@ -43,7 +43,11 @@ diff --git a/src/humulus/templates/auth/login.html b/src/humulus/templates/auth/login.html new file mode 100644 index 0000000..9570856 --- /dev/null +++ b/src/humulus/templates/auth/login.html @@ -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.hidden_tag() }} +
+ {{ render_field_with_errors(form.password) }} +
+
+ +
+
+{% endblock %} diff --git a/src/humulus/templates/recipes/index.html b/src/humulus/templates/recipes/index.html index 7d61f3a..11e06ab 100644 --- a/src/humulus/templates/recipes/index.html +++ b/src/humulus/templates/recipes/index.html @@ -21,9 +21,11 @@ {% block body %}

Recipes

+{% if session.logged_in %} +{% endif %}
diff --git a/src/humulus/templates/recipes/info.html b/src/humulus/templates/recipes/info.html index 01b7c36..08b70b2 100644 --- a/src/humulus/templates/recipes/info.html +++ b/src/humulus/templates/recipes/info.html @@ -143,10 +143,12 @@ {#- Buttons to do things -#} +{% if session.logged_in %}
Update Recipe {{ render_delete_button('Delete Recipe', 'deleteRecipe', 'btn-danger') }}
{{ render_delete_modal(url_for('recipes.delete', id=recipe._id), 'deleteRecipe', recipe.name) }} +{% endif %}
Recipe revision: {{ recipe._rev }}
{% endblock %} diff --git a/tests/conftest.py b/tests/conftest.py index 159e3dd..a53c14c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,7 +30,8 @@ def app(): 'COUCH_PASSWORD': 'password', 'COUCH_DATABASE': dbname, 'WTF_CSRF_ENABLED': False, - 'SECRET_KEY': 'testing' + 'SECRET_KEY': 'testing', + 'HUMULUS_PASSWORD': 'password' }) with app.app_context(): @@ -132,3 +133,22 @@ def runner(app): @pytest.fixture def client(app): 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) diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..a6daf9a --- /dev/null +++ b/tests/test_auth.py @@ -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) diff --git a/tests/test_recipes.py b/tests/test_recipes.py index 259a114..d5ec43e 100644 --- a/tests/test_recipes.py +++ b/tests/test_recipes.py @@ -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 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') assert response.status_code == 200 @@ -95,8 +100,13 @@ def test_create(client, app): assert doc['efficiency'] == '65' -def test_update(client, app): +def test_update(client, app, auth): """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 response = client.get('/recipes/update/awesome-lager') 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.""" + # Try to delete a document without logging in 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') assert response.status_code == 404