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
from datetime import timedelta
from pathlib import Path
from time import perf_counter
import jwt
import ujson as json
import hupper
import minicli
@ -9,7 +11,7 @@ from jinja2 import Environment, PackageLoader, select_autoescape
from roll import Roll, Response
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
@ -59,6 +61,42 @@ cors(app, methods="*", headers="*")
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")
async def attach_request(request, response):
response.request = request
@ -69,17 +107,49 @@ async def on_startup():
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"])
@auth_required
async def home(request, response):
response.html("home.html", deliveries=Delivery.all())
@app.route("/livraison/new", methods=["GET"])
@auth_required
async def new_delivery(request, response):
response.html("edit_delivery.html", delivery={})
@app.route("/livraison/new", methods=["POST"])
@auth_required
async def create_delivery(request, response):
form = request.form
data = {}
@ -93,6 +163,7 @@ async def create_delivery(request, response):
@app.route("/livraison/{id}/importer/produits", methods=["POST"])
@auth_required
async def import_products(request, response, id):
delivery = Delivery.load(id)
delivery.products = []
@ -107,12 +178,14 @@ async def import_products(request, response, id):
@app.route("/livraison/{id}/edit", methods=["GET"])
@auth_required
async def edit_delivery(request, response, id):
delivery = Delivery.load(id)
response.html("edit_delivery.html", {"delivery": delivery})
@app.route("/livraison/{id}/edit", methods=["POST"])
@auth_required
async def post_delivery(request, response, id):
delivery = Delivery.load(id)
form = request.form
@ -125,30 +198,40 @@ async def post_delivery(request, response, id):
@app.route("/livraison/{id}", methods=["GET"])
@auth_required
async def view_delivery(request, response, id):
delivery = Delivery.load(id)
response.html("delivery.html", {"delivery": delivery})
@app.route("/livraison/{id}/commander", methods=["GET"])
@auth_required
async def order_form(request, response, 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()
response.html(
"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"])
async def signing_list(request, response, id):
@auth_required
async def signing_sheet(request, response, id):
delivery = Delivery.load(id)
response.html(
"signing_list.html", {"delivery": delivery}
)
response.html("signing_sheet.html", {"delivery": delivery})
@app.route("/livraison/{id}/commander", methods=["POST"])
@auth_required
async def place_order(request, response, id):
delivery = Delivery.load(id)
email = request.query.get("email")
@ -167,6 +250,7 @@ async def place_order(request, response, id):
@app.route("/livraison/{id}/importer/commande", methods=["POST"])
@auth_required
async def import_commande(request, response, id):
email = request.form.get("email")
order = Order()
@ -185,20 +269,22 @@ async def import_commande(request, response, id):
@app.route("/livraison/{id}/rapport.xlsx", methods=["GET"])
@auth_required
async def xls_report(request, response, id):
delivery = Delivery.load(id)
response.body = reports.summary(delivery)
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"
@app.route("/livraison/{id}/rapport-complet.xlsx", methods=["GET"])
@auth_required
async def xls_full_report(request, response, id):
delivery = Delivery.load(id)
response.body = reports.full(delivery)
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"

View file

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

View file

@ -10,7 +10,7 @@ Voici le sésame:
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))
try:
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)
except smtplib.SMTPException:
raise RuntimeError

View file

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

View file

@ -5,7 +5,7 @@ from openpyxl.writer.excel import save_virtual_workbook
def summary(delivery):
wb = Workbook()
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"])
for product in delivery.products:
wanted = delivery.product_wanted(product)
@ -25,7 +25,7 @@ def summary(delivery):
def full(delivery):
wb = Workbook()
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"]
ws.append(headers)
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;
align-items: center;
}
.logged-in {
text-decoration: underline;
font-variant: small-caps;
}
main {
padding: 1rem;
}
@ -164,7 +168,7 @@ main {
button,
a.button,
input[type=submit] {
display: flex;
display: inline-flex;
align-items: center;
justify-content: center;
width: auto;
@ -377,6 +381,9 @@ hr {
.notification.success {
background-color: #0f8796;
}
.notification.error {
background-color: #e10055;
}
.notification i {
font-size: 2rem;
}

View file

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

View file

@ -1,7 +1,7 @@
{% extends "base.html" %}
{% 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" %}
<table class="delivery">
<tbody>
@ -52,17 +52,14 @@
<a href="/livraison/{{ delivery.id }}/émargement" target="_blank"><i class="icon-document"></i> Liste d'émargement</a>
</li>
<li>
<label for="import-command" class="toggle-label"><i class="icon-paperclip"></i> Importer une commande</label>
<input type="checkbox" id="import-command" class="toggle">
<div class="toggle-container">
<label for="import-command" class="toggle-label">Fermer</label>
<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 une commande">
</form>
</div>
{% with unique_id="import-command" %}
{% include "includes/modal_import_command.html" %}
{% endwith %}
</li>
<li>
{% with unique_id="add-command" %}
{% include "includes/modal_add_command.html" %}
{% endwith %}
</li>
</ul>
{% endblock body %}

View file

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

View file

@ -6,11 +6,6 @@
<li>
<h3><a href="/livraison/{{ delivery.id }}"><i class="icon-hotairballoon"></i> {{ delivery.producer }}</a></h3>
{% 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>
<hr>
{% endfor %}

View file

@ -1,5 +1,5 @@
<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-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>

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>
<th class="product">{{ product.name }}
{% if product.description or product.img %}
<label for="toggleControl{{ loop.index }}" class="toggle-label">(Détails)</label>
<input type="checkbox" id="toggleControl{{ loop.index }}" class="toggle">
<label for="toggleControl{{ loop.index }}" class="toggle-background"></label>
<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>
{% with unique_id=loop.index %}
{% include "includes/modal_product.html" %}
{% endwith %}
{% endif %}</p>
</th>
<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
from roll.extensions import traceback
from roll.testing import Client as BaseClient
from kaba import app as kaba_app
from kaba import config as kconfig
from kaba import create_token
from kaba.models import Delivery, Person
@ -20,6 +22,39 @@ def pytest_runtest_setup(item):
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
def app(): # Requested by Roll testing utilities.
traceback(kaba_app)

View file

@ -1,6 +1,5 @@
import pytest
pytestmark = pytest.mark.asyncio
@ -14,3 +13,10 @@ async def test_home_should_list_active_delivery(client, delivery):
resp = await client.get('/')
assert resp.status == 200
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=/"