Basic auth management

This commit is contained in:
Yohan Boniface 2019-03-23 21:53:33 +01:00
parent 8bb0dce145
commit ab1710d1e4
21 changed files with 228 additions and 56 deletions

View file

@ -1,7 +1,9 @@
import csv import csv
from datetime import timedelta
from pathlib import Path from pathlib import Path
from time import perf_counter from time import perf_counter
import jwt
import ujson as json import ujson as json
import hupper import hupper
import minicli import minicli
@ -9,7 +11,7 @@ from jinja2 import Environment, PackageLoader, select_autoescape
from roll import Roll, Response from roll import Roll, Response
from roll.extensions import cors, options, traceback, simple_server, static from roll.extensions import cors, options, traceback, simple_server, static
from . import config, reports from . import config, reports, session, utils, emails
from .models import Delivery, Order, Person, Product, ProductOrder from .models import Delivery, Order, Person, Product, ProductOrder
@ -59,6 +61,42 @@ cors(app, methods="*", headers="*")
options(app) options(app)
def auth_required(view):
async def redirect(request, response, *a, **k):
# FIXME do not return a view when Roll allows it.
response.redirect = f"/sésame?next={request.path}"
def wrapper(request, response, *args, **kwargs):
token = request.cookies.get("token")
email = None
if token:
decoded = read_token(token)
email = decoded.get("sub")
if not email:
return redirect(request, response, *args, **kwargs)
user = Person(email=email)
request["user"] = user
session.user.set(user)
return view(request, response, *args, **kwargs)
return wrapper
def create_token(email):
return jwt.encode(
{"sub": str(email), "exp": utils.utcnow() + timedelta(days=7)},
config.SECRET,
config.JWT_ALGORITHM,
)
def read_token(token):
try:
return jwt.decode(token, config.SECRET, algorithms=[config.JWT_ALGORITHM])
except (jwt.DecodeError, jwt.ExpiredSignatureError):
return {}
@app.listen("request") @app.listen("request")
async def attach_request(request, response): async def attach_request(request, response):
response.request = request response.request = request
@ -69,17 +107,49 @@ async def on_startup():
configure() configure()
@app.route("/sésame", methods=["GET"])
async def sesame(request, response):
response.html("sesame.html")
@app.route("/sésame", methods=["POST"])
async def send_sesame(request, response):
email = request.form.get("email")
token = create_token(email)
emails.send(
email,
"Sésame Panio",
emails.ACCESS_GRANTED.format(hostname=request.host, token=token.decode()),
)
response.message(f"Un sésame vous a été envoyé à l'adresse '{email}'")
response.redirect = "/"
@app.route("/sésame/{token}", methods=["GET"])
async def set_sesame(request, response, token):
decoded = read_token(token)
if not decoded:
response.message("Sésame invalide :(", status="error")
else:
response.message("Yay! Le sésame a fonctionné. Bienvenue à bord! :)")
response.cookies.set(name="token", value=token)
response.redirect = "/"
@app.route("/", methods=["GET"]) @app.route("/", methods=["GET"])
@auth_required
async def home(request, response): async def home(request, response):
response.html("home.html", deliveries=Delivery.all()) response.html("home.html", deliveries=Delivery.all())
@app.route("/livraison/new", methods=["GET"]) @app.route("/livraison/new", methods=["GET"])
@auth_required
async def new_delivery(request, response): async def new_delivery(request, response):
response.html("edit_delivery.html", delivery={}) response.html("edit_delivery.html", delivery={})
@app.route("/livraison/new", methods=["POST"]) @app.route("/livraison/new", methods=["POST"])
@auth_required
async def create_delivery(request, response): async def create_delivery(request, response):
form = request.form form = request.form
data = {} data = {}
@ -93,6 +163,7 @@ async def create_delivery(request, response):
@app.route("/livraison/{id}/importer/produits", methods=["POST"]) @app.route("/livraison/{id}/importer/produits", methods=["POST"])
@auth_required
async def import_products(request, response, id): async def import_products(request, response, id):
delivery = Delivery.load(id) delivery = Delivery.load(id)
delivery.products = [] delivery.products = []
@ -107,12 +178,14 @@ async def import_products(request, response, id):
@app.route("/livraison/{id}/edit", methods=["GET"]) @app.route("/livraison/{id}/edit", methods=["GET"])
@auth_required
async def edit_delivery(request, response, id): async def edit_delivery(request, response, id):
delivery = Delivery.load(id) delivery = Delivery.load(id)
response.html("edit_delivery.html", {"delivery": delivery}) response.html("edit_delivery.html", {"delivery": delivery})
@app.route("/livraison/{id}/edit", methods=["POST"]) @app.route("/livraison/{id}/edit", methods=["POST"])
@auth_required
async def post_delivery(request, response, id): async def post_delivery(request, response, id):
delivery = Delivery.load(id) delivery = Delivery.load(id)
form = request.form form = request.form
@ -125,30 +198,40 @@ async def post_delivery(request, response, id):
@app.route("/livraison/{id}", methods=["GET"]) @app.route("/livraison/{id}", methods=["GET"])
@auth_required
async def view_delivery(request, response, id): async def view_delivery(request, response, id):
delivery = Delivery.load(id) delivery = Delivery.load(id)
response.html("delivery.html", {"delivery": delivery}) response.html("delivery.html", {"delivery": delivery})
@app.route("/livraison/{id}/commander", methods=["GET"]) @app.route("/livraison/{id}/commander", methods=["GET"])
@auth_required
async def order_form(request, response, id): async def order_form(request, response, id):
delivery = Delivery.load(id) delivery = Delivery.load(id)
email = request.query.get("email") email = request.query.get("email", None)
if not email:
user = session.user.get(None)
if user:
email = user.email
if email:
order = delivery.orders.get(email) or Order() order = delivery.orders.get(email) or Order()
response.html( response.html(
"place_order.html", {"delivery": delivery, "person": email, "order": order} "place_order.html", {"delivery": delivery, "person": email, "order": order}
) )
else:
response.message("Impossible de comprendre pour qui passer commande…", "error")
response.redirect = request.path
@app.route("/livraison/{id}/émargement", methods=["GET"]) @app.route("/livraison/{id}/émargement", methods=["GET"])
async def signing_list(request, response, id): @auth_required
async def signing_sheet(request, response, id):
delivery = Delivery.load(id) delivery = Delivery.load(id)
response.html( response.html("signing_sheet.html", {"delivery": delivery})
"signing_list.html", {"delivery": delivery}
)
@app.route("/livraison/{id}/commander", methods=["POST"]) @app.route("/livraison/{id}/commander", methods=["POST"])
@auth_required
async def place_order(request, response, id): async def place_order(request, response, id):
delivery = Delivery.load(id) delivery = Delivery.load(id)
email = request.query.get("email") email = request.query.get("email")
@ -167,6 +250,7 @@ async def place_order(request, response, id):
@app.route("/livraison/{id}/importer/commande", methods=["POST"]) @app.route("/livraison/{id}/importer/commande", methods=["POST"])
@auth_required
async def import_commande(request, response, id): async def import_commande(request, response, id):
email = request.form.get("email") email = request.form.get("email")
order = Order() order = Order()
@ -185,20 +269,22 @@ async def import_commande(request, response, id):
@app.route("/livraison/{id}/rapport.xlsx", methods=["GET"]) @app.route("/livraison/{id}/rapport.xlsx", methods=["GET"])
@auth_required
async def xls_report(request, response, id): async def xls_report(request, response, id):
delivery = Delivery.load(id) delivery = Delivery.load(id)
response.body = reports.summary(delivery) response.body = reports.summary(delivery)
mimetype = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" mimetype = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
response.headers["Content-Disposition"] = f'attachment; filename="export.xlsx"' response.headers["Content-Disposition"] = f'attachment; filename="epinamap.xlsx"'
response.headers["Content-Type"] = f"{mimetype}; charset=utf-8" response.headers["Content-Type"] = f"{mimetype}; charset=utf-8"
@app.route("/livraison/{id}/rapport-complet.xlsx", methods=["GET"]) @app.route("/livraison/{id}/rapport-complet.xlsx", methods=["GET"])
@auth_required
async def xls_full_report(request, response, id): async def xls_full_report(request, response, id):
delivery = Delivery.load(id) delivery = Delivery.load(id)
response.body = reports.full(delivery) response.body = reports.full(delivery)
mimetype = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" mimetype = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
response.headers["Content-Disposition"] = f'attachment; filename="export.xlsx"' response.headers["Content-Disposition"] = f'attachment; filename="epinamap.xlsx"'
response.headers["Content-Type"] = f"{mimetype}; charset=utf-8" response.headers["Content-Type"] = f"{mimetype}; charset=utf-8"

View file

@ -5,10 +5,11 @@ getenv = os.environ.get
DATA_ROOT = Path(__file__).parent.parent / "db" DATA_ROOT = Path(__file__).parent.parent / "db"
SECRET = "sikretfordevonly" SECRET = "sikretfordevonly"
# JWT_ALGORITHM = "HS256" JWT_ALGORITHM = "HS256"
SEND_EMAILS = False SEND_EMAILS = False
SMTP_HOST = "smtp.gmail.com" SMTP_HOST = "mail.gandi.net"
SMTP_PASSWORD = "" SMTP_PASSWORD = ""
SMTP_LOGIN = ""
FROM_EMAIL = "contact@epinamap.org" FROM_EMAIL = "contact@epinamap.org"

View file

@ -10,7 +10,7 @@ Voici le sésame:
https://{hostname}/sésame/{token} https://{hostname}/sésame/{token}
Les gentils gens d'Épinamap Les gentils copains d'Épinamap
""" """
@ -24,7 +24,7 @@ def send(to, subject, body):
return print("Sending email", str(msg)) return print("Sending email", str(msg))
try: try:
server = smtplib.SMTP_SSL(config.SMTP_HOST) server = smtplib.SMTP_SSL(config.SMTP_HOST)
server.login(config.FROM_EMAIL, config.SMTP_PASSWORD) server.login(config.SMTP_LOGIN, config.SMTP_PASSWORD)
server.send_message(msg) server.send_message(msg)
except smtplib.SMTPException: except smtplib.SMTPException:
raise RuntimeError raise RuntimeError

View file

@ -8,7 +8,7 @@ from typing import List, Dict
import yaml import yaml
from . import config, utils from . import config
class DoesNotExist(ValueError): class DoesNotExist(ValueError):

View file

@ -5,7 +5,7 @@ from openpyxl.writer.excel import save_virtual_workbook
def summary(delivery): def summary(delivery):
wb = Workbook() wb = Workbook()
ws = wb.active ws = wb.active
ws.title = f"Commande Epinamap - {delivery.producer} - {delivery.when.date()}" ws.title = f"{delivery.producer} {delivery.when.date()}"
ws.append(["ref", "produit", "prix", "unités", "total"]) ws.append(["ref", "produit", "prix", "unités", "total"])
for product in delivery.products: for product in delivery.products:
wanted = delivery.product_wanted(product) wanted = delivery.product_wanted(product)
@ -25,7 +25,7 @@ def summary(delivery):
def full(delivery): def full(delivery):
wb = Workbook() wb = Workbook()
ws = wb.active ws = wb.active
ws.title = f"Epinamap - {delivery.producer} - {delivery.when.date()}" ws.title = f"{delivery.producer} {delivery.when.date()}"
headers = ["ref", "produit", "prix"] + [e for e in delivery.orders] + ["total"] headers = ["ref", "produit", "prix"] + [e for e in delivery.orders] + ["total"]
ws.append(headers) ws.append(headers)
for product in delivery.products: for product in delivery.products:

3
kaba/session.py Normal file
View file

@ -0,0 +1,3 @@
import contextvars
user = contextvars.ContextVar("user")

View file

@ -157,6 +157,10 @@ nav {
display: flex; display: flex;
align-items: center; align-items: center;
} }
.logged-in {
text-decoration: underline;
font-variant: small-caps;
}
main { main {
padding: 1rem; padding: 1rem;
} }
@ -164,7 +168,7 @@ main {
button, button,
a.button, a.button,
input[type=submit] { input[type=submit] {
display: flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: auto; width: auto;
@ -377,6 +381,9 @@ hr {
.notification.success { .notification.success {
background-color: #0f8796; background-color: #0f8796;
} }
.notification.error {
background-color: #e10055;
}
.notification i { .notification i {
font-size: 2rem; font-size: 2rem;
} }

View file

@ -20,6 +20,9 @@
<h1><a href="/">Panio</a> <small>Les paniers piano d'Épinamap</small></h1> <h1><a href="/">Panio</a> <small>Les paniers piano d'Épinamap</small></h1>
<nav> <nav>
<a href="/livraison/new">Nouvelle livraison</a> <a href="/livraison/new">Nouvelle livraison</a>
{% if request["user"] %}
&nbsp;&nbsp;{{ request["user"].email }}</span>
{% endif %}
</nav> </nav>
</section> </section>
</header> </header>

View file

@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block body %} {% block body %}
<h3>{{ delivery.producer }}</h3> <h3>{{ delivery.producer }} <a class="button" href="/livraison/{{ delivery.id }}/commander">Ma commande</a></h3>
{% include "includes/delivery_head.html" %} {% include "includes/delivery_head.html" %}
<table class="delivery"> <table class="delivery">
<tbody> <tbody>
@ -52,17 +52,14 @@
<a href="/livraison/{{ delivery.id }}/émargement" target="_blank"><i class="icon-document"></i> Liste d'émargement</a> <a href="/livraison/{{ delivery.id }}/émargement" target="_blank"><i class="icon-document"></i> Liste d'émargement</a>
</li> </li>
<li> <li>
<label for="import-command" class="toggle-label"><i class="icon-paperclip"></i> Importer une commande</label> {% with unique_id="import-command" %}
<input type="checkbox" id="import-command" class="toggle"> {% include "includes/modal_import_command.html" %}
<div class="toggle-container"> {% endwith %}
<label for="import-command" class="toggle-label">Fermer</label> </li>
<p>Colonnes: ref*, wanted*</p> <li>
<form action="/livraison/{{ delivery.id }}/importer/commande" method="post" enctype="multipart/form-data"> {% with unique_id="add-command" %}
<input type="file" name="data"> {% include "includes/modal_add_command.html" %}
<input type="email" name="email" placeholder="email"> {% endwith %}
<input type="submit" name="Importer une commande">
</form>
</div>
</li> </li>
</ul> </ul>
{% endblock body %} {% endblock body %}

View file

@ -6,24 +6,24 @@
<form method="post"> <form method="post">
<label> <label>
<h5>Producteur</h5> <h5>Producteur</h5>
<input type="text" name="producer" value="{{ delivery.producer or '' }}"> <input type="text" name="producer" value="{{ delivery.producer or '' }}" required>
</label> </label>
<label> <label>
<h5>Description</h5> <h5>Description des produits</h5>
<input type="text" name="description" value="{{ delivery.description or '' }}"> <input type="text" name="description" value="{{ delivery.description or '' }}" required>
</label> </label>
<label> <label>
<h5>Lieu</h5> <h5>Lieu</h5>
<input type="text" name="where" value="{{ delivery.where or '' }}"> <input type="text" name="where" value="{{ delivery.where or '' }}" required>
</label> </label>
<label> <label>
<h5>Date de livraison</h5> <h5>Date de livraison</h5>
<input type="date" name="when" value="{{ delivery.when.date() if delivery.when else '' }}"> <input type="date" name="when" value="{{ delivery.when.date() if delivery.when else '' }}" required>
<input type="time" name="when_time" value="{{ delivery.when.time() if delivery.when else '' }}"> <input type="time" name="when_time" value="{{ delivery.when.time() if delivery.when else '' }}" required>
</label> </label>
<label> <label>
<h5>Date de limite de commande</h5> <h5>Date de limite de commande</h5>
<input type="date" name="order_before" value="{{ delivery.order_before.date() if delivery.order_before else '' }}"> <input type="date" name="order_before" value="{{ delivery.order_before.date() if delivery.order_before else '' }}" required>
</label> </label>
<div> <div>
<input type="submit" name="submit" value="Valider"> <input type="submit" name="submit" value="Valider">

View file

@ -6,11 +6,6 @@
<li> <li>
<h3><a href="/livraison/{{ delivery.id }}"><i class="icon-hotairballoon"></i> {{ delivery.producer }}</a></h3> <h3><a href="/livraison/{{ delivery.id }}"><i class="icon-hotairballoon"></i> {{ delivery.producer }}</a></h3>
{% include "includes/delivery_head.html" %} {% include "includes/delivery_head.html" %}
<!--form action="/livraison/{{ delivery.id }}/commander">
<label for="email">Commander</label>
<input type="email" name="email" placeholder="Mon courriel">
<input type="submit" value="Commander">
</form-->
</li> </li>
<hr> <hr>
{% endfor %} {% endfor %}

View file

@ -1,5 +1,5 @@
<ul class="delivery-head"> <ul class="delivery-head">
<li><i class="icon-basket"></i> {{ delivery.description }}</li> <li><i class="icon-basket"></i> <strong>Produits</strong> {{ delivery.description }}</li>
<li><i class="icon-map-pin"></i> <strong>Lieu</strong> {{ delivery.where }}</li> <li><i class="icon-map-pin"></i> <strong>Lieu</strong> {{ delivery.where }}</li>
<li><i class="icon-clock"></i> <strong>Date de livraison</strong> <time datetime="{{ delivery.when }}">{{ delivery.when }}</time></li> <li><i class="icon-clock"></i> <strong>Date de livraison</strong> <time datetime="{{ delivery.when }}">{{ delivery.when }}</time></li>
<li><i class="icon-hourglass"></i> {% if delivery.is_open %}<strong>Date limite de commande</strong> <time datetime="{{ delivery.order_before.date() }}">{{ delivery.order_before.date() }}</time>{% else %}<strong>Fermée</strong>{% endif %}</li> <li><i class="icon-hourglass"></i> {% if delivery.is_open %}<strong>Date limite de commande</strong> <time datetime="{{ delivery.order_before.date() }}">{{ delivery.order_before.date() }}</time>{% else %}<strong>Fermée</strong>{% endif %}</li>

View file

@ -0,0 +1,8 @@
<label for="modal-{{ unique_id }}" class="toggle-label">{% block modal_label %}{% endblock %}</label>
<input type="checkbox" id="modal-{{ unique_id }}" class="toggle">
<label for="modal-{{ unique_id }}" class="toggle-background"></label>
<div class="toggle-container">
{% block modal_body %}
{% endblock modal_body %}
<label for="modal-{{ unique_id }}" class="toggle-label">Fermer</label>
</div>

View file

@ -0,0 +1,10 @@
{% extends "includes/modal.html" %}
{% block modal_label %}<i class="icon-hazardous"></i> Ajouter une commande{% endblock modal_label %}
{% block modal_body %}
<form action="/livraison/{{ delivery.id }}/commander">
<h4>Ajouter une commande pour quelqu'un d'autre</h4>
<input type="email" name="email" placeholder="Courriel" required>
<input type="submit" value="Commander">
</form>
{% endblock modal_body %}

View file

@ -0,0 +1,12 @@
{% extends "includes/modal.html" %}
{% block modal_label %}<i class="icon-paperclip"></i> Importer une commande{% endblock modal_label %}
{% block modal_body %}
<h4>Importer une commande</h4>
<p>Colonnes: ref*, wanted*</p>
<form action="/livraison/{{ delivery.id }}/importer/commande" method="post" enctype="multipart/form-data">
<input type="file" name="data">
<input type="email" name="email" placeholder="email">
<input type="submit" name="Importer">
</form>
{% endblock modal_body %}

View file

@ -0,0 +1,9 @@
{% extends "includes/modal.html" %}
{% block modal_label %}(Détails){% endblock modal_label %}
{% block modal_body %}
<p>{{ product.description }}</p>
<p>{% if product.img %}
<img src="{{ product.img }}">
{% endif %}</p>
{% endblock modal_body %}

View file

@ -11,16 +11,9 @@
<tr> <tr>
<th class="product">{{ product.name }} <th class="product">{{ product.name }}
{% if product.description or product.img %} {% if product.description or product.img %}
<label for="toggleControl{{ loop.index }}" class="toggle-label">(Détails)</label> {% with unique_id=loop.index %}
<input type="checkbox" id="toggleControl{{ loop.index }}" class="toggle"> {% include "includes/modal_product.html" %}
<label for="toggleControl{{ loop.index }}" class="toggle-background"></label> {% endwith %}
<div class="toggle-container">
<p>{{ product.description }}</p>
<p>{% if product.img %}
<img src="{{ product.img }}">
{% endif %}</p>
<label for="toggleControl{{ loop.index }}" class="toggle-label">Fermer</label>
</div>
{% endif %}</p> {% endif %}</p>
</th> </th>
<td>{{ product.price }} €</td><td class="with-input"><input type="number" name="{{ product.ref }}" value="{{ order.get_quantity(product) }}"></td></tr> <td>{{ product.price }} €</td><td class="with-input"><input type="number" name="{{ product.ref }}" value="{{ order.get_quantity(product) }}"></td></tr>

View file

@ -0,0 +1,7 @@
{% extends "base.html" %}
{% block body %}
<form method="post">
<input type="email" name="email" placeholder="Mon courriel" required>
<input type="submit" value="Envoyez-moi un sésame">
</form>
{% endblock body %}

View file

@ -3,9 +3,11 @@ from datetime import datetime, timedelta
import pytest import pytest
from roll.extensions import traceback from roll.extensions import traceback
from roll.testing import Client as BaseClient
from kaba import app as kaba_app from kaba import app as kaba_app
from kaba import config as kconfig from kaba import config as kconfig
from kaba import create_token
from kaba.models import Delivery, Person from kaba.models import Delivery, Person
@ -20,6 +22,39 @@ def pytest_runtest_setup(item):
path.unlink() path.unlink()
class Client(BaseClient):
headers = {}
async def request(
self, path, method="GET", body=b"", headers=None, content_type=None
):
# TODO move this to Roll upstream?
headers = headers or {}
for key, value in self.headers.items():
headers.setdefault(key, value)
return await super().request(path, method, body, headers, content_type)
def login(self, email="foo@bar.org"):
token = create_token(email)
self.headers["Cookie"] = f"token={token.decode()}"
def logout(self):
try:
del self.headers["Cookie"]
except KeyError:
pass
@pytest.fixture
def client(app, event_loop):
app.loop = event_loop
app.loop.run_until_complete(app.startup())
client = Client(app)
client.login()
yield client
app.loop.run_until_complete(app.shutdown())
@pytest.fixture @pytest.fixture
def app(): # Requested by Roll testing utilities. def app(): # Requested by Roll testing utilities.
traceback(kaba_app) traceback(kaba_app)

View file

@ -1,6 +1,5 @@
import pytest import pytest
pytestmark = pytest.mark.asyncio pytestmark = pytest.mark.asyncio
@ -14,3 +13,10 @@ async def test_home_should_list_active_delivery(client, delivery):
resp = await client.get('/') resp = await client.get('/')
assert resp.status == 200 assert resp.status == 200
assert delivery.producer in resp.body.decode() assert delivery.producer in resp.body.decode()
async def test_home_should_redirect_to_login_if_not_logged(client):
client.logout()
resp = await client.get('/')
assert resp.status == 302
assert resp.headers["Location"] == "/sésame?next=/"