Add google sign-in

Also, try to be a bit more correct with CSRF and CORS.
It works on my machine.
This commit is contained in:
Paul Mathieu 2025-08-06 15:27:45 +02:00
parent 69327d9edf
commit c219091c2c
8 changed files with 145 additions and 26 deletions

View File

@ -1,7 +1,7 @@
FROM alpine:3.18 FROM alpine:3.18
RUN apk --no-cache add python3 inkscape bash imagemagick ghostscript font-noto RUN apk --no-cache add python3 inkscape bash imagemagick ghostscript font-noto py3-pip
RUN apk --no-cache add py3-pip && pip3 install --break-system-packages django tzdata gunicorn RUN pip3 install --break-system-packages django tzdata gunicorn google-api-python-client
ADD backend /zetikettes ADD backend /zetikettes

View File

@ -0,0 +1,15 @@
from django.http import HttpResponse
def cors_everywhere(get_response):
def middleware(request):
if request.method == 'OPTIONS':
response = HttpResponse()
else:
response = get_response(request)
response["Access-Control-Allow-Origin"] = request.headers.get("Origin", "*")
response["Access-Control-Allow-Credentials"] = "true"
response["Access-Control-Allow-Headers"] = "x-csrftoken"
return response
return middleware

View File

@ -1,14 +1,44 @@
import json import json
import sys
from django.conf import settings from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.http import JsonResponse from django.http import JsonResponse
from django.shortcuts import render from django.shortcuts import render
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from google.auth.transport import requests
from google.oauth2 import id_token
from .planche import generate as stickersheet from .planche import generate as stickersheet
from .models import Tikette, Tikategory from .models import Tikette, Tikategory, Tizer
CORS={'access-control-allow-origin': '*'}
def ok(payload=None):
return JsonResponse({'status': 'ok', **(payload or {})})
def post_please(f):
def __f(request):
if request.method != "POST":
return JsonResponse({'status': 'notok', 'message': 'this isn\'t the way'},
status=405, headers={'Allow': 'POST'})
return f(request)
return __f
def auth_only(f):
def __f(request):
# check that email is valid
# exp?
print(request.META, file=sys.stderr)
if 'user_data' not in request.session:
raise PermissionDenied('Not logged in')
email = request.session['user_data']['email']
resp = Tizer.objects.filter(email=email)
if not resp:
raise PermissionDenied('User not found')
return f(request)
return __f
def quirk_bold_allergens(ingredients): def quirk_bold_allergens(ingredients):
@ -21,6 +51,7 @@ def quirk_bold_allergens(ingredients):
return ", ".join(out) return ", ".join(out)
@auth_only
def get_list(request): def get_list(request):
tikettes = [{ tikettes = [{
'id': x.id, 'id': x.id,
@ -35,22 +66,21 @@ def get_list(request):
'color': x.color, 'color': x.color,
'subs': {x.name: x.default for x in x.category.subs.all()}, 'subs': {x.name: x.default for x in x.category.subs.all()},
} for x in Tikette.objects.all()] } for x in Tikette.objects.all()]
return JsonResponse({'status': 'ok', 'tikettes': tikettes}, headers=CORS) return ok({'tikettes': tikettes})
@auth_only
def get_categories(request): def get_categories(request):
tikats = [{ tikats = [{
'id': x.id, 'id': x.id,
'name': x.name, 'name': x.name,
} for x in Tikategory.objects.all()] } for x in Tikategory.objects.all()]
return JsonResponse({'status': 'ok', 'tikats': tikats}, headers=CORS) return ok({'tikats': tikats})
@csrf_exempt @auth_only
@post_please
def generate(request): def generate(request):
if request.method != "POST":
return JsonResponse({'status': 'notok', 'message': 'this isn\'t the way'})
payload = json.loads(request.body) payload = json.loads(request.body)
subs = dict(payload['subs']) subs = dict(payload['subs'])
@ -61,27 +91,47 @@ def generate(request):
out_dir=settings.TIKETTE_OUT_DIR, out_dir=settings.TIKETTE_OUT_DIR,
landscape=payload['landscape']) landscape=payload['landscape'])
return JsonResponse({'status': 'ok', 'file': pdfpath}, headers=CORS) return ok({'file': pdfpath})
@csrf_exempt @auth_only
@post_please
def newtikette(request): def newtikette(request):
if request.method != "POST":
return JsonResponse({'status': 'notok', 'message': 'this isn\'t the way'})
payload = json.loads(request.body) payload = json.loads(request.body)
tikette = Tikette(**payload) tikette = Tikette(**payload)
tikette.save() tikette.save()
return JsonResponse({'status': 'ok'}, headers=CORS) return ok()
@csrf_exempt @auth_only
@post_please
def deletetikette(request): def deletetikette(request):
if request.method != "POST":
return JsonResponse({'status': 'notok', 'message': 'this isn\'t the way'})
payload = json.loads(request.body) payload = json.loads(request.body)
Tikette.objects.get(id=payload["id"]).delete() Tikette.objects.get(id=payload["id"]).delete()
return JsonResponse({'status': 'ok'}, headers=CORS) return ok()
@csrf_exempt
@post_please
def signin(request):
payload = json.loads(request.body)
token = payload['token']
try:
user_data = id_token.verify_oauth2_token(
token, requests.Request(), settings.GOOGLE_OAUTH_CLIENT_ID
)
except ValueError:
raise
return JsonResponse({'status': 'notok'}, status=403)
request.session['user_data'] = user_data
return ok(user_data)
def signout(request):
del request.session['user_data']
return ok()

View File

@ -53,6 +53,7 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'tikette.middleware.cors_everywhere',
] ]
ROOT_URLCONF = 'zetikettes.urls' ROOT_URLCONF = 'zetikettes.urls'
@ -129,3 +130,6 @@ STATIC_ROOT = 'www_static'
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# not a secret
GOOGLE_OAUTH_CLIENT_ID = '634510965520-c5l7f15fn4koraqhpqfe01ssn8v0q2qk.apps.googleusercontent.com'

View File

@ -29,4 +29,6 @@ urlpatterns = [
path('generate', tikette.views.generate), path('generate', tikette.views.generate),
path('newtikette', tikette.views.newtikette), path('newtikette', tikette.views.newtikette),
path('deletetikette', tikette.views.deletetikette), path('deletetikette', tikette.views.deletetikette),
path('signin', tikette.views.signin),
path('signout', tikette.views.signout),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@ -1 +1,2 @@
const backend_api = '/zetikettes/srv/' const backend_api = '/zetikettes/srv/'
const google_oauth_client_id = '634510965520-c5l7f15fn4koraqhpqfe01ssn8v0q2qk.apps.googleusercontent.com';

View File

@ -14,6 +14,7 @@
<!-- Compiled and minified JavaScript --> <!-- Compiled and minified JavaScript -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
<script src="https://accounts.google.com/gsi/client"></script>
<script src="jscolor.min.js"></script> <script src="jscolor.min.js"></script>
<script src="config.js"></script> <script src="config.js"></script>
@ -46,6 +47,7 @@ main {
</nav> </nav>
<main class="container row"> <main class="container row">
<p></p> <p></p>
<div class="col s4 offset-s4" id="signin-prompt" style="display: none"></div>
<div class="col m6 offset-m3 s12"> <div class="col m6 offset-m3 s12">
<a class="modal-trigger btn orange hide-on-large-only" href="#newproduct"><i class="material-icons left">add</i>Nouveau produit</a> <a class="modal-trigger btn orange hide-on-large-only" href="#newproduct"><i class="material-icons left">add</i>Nouveau produit</a>
<ul class="collapsible" id="appbody"> <ul class="collapsible" id="appbody">

View File

@ -8,6 +8,28 @@ const params = {
var tikats; var tikats;
function getCookie(name) {
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
cookie = cookie.trim();
if (cookie.startsWith(name + '=')) {
return cookie.substring(name.length + 1);
}
}
return null;
}
function post(url, data) {
const csrf_token = getCookie('csrftoken');
return $.ajax({
url,
data: JSON.stringify(data),
method: 'POST',
xhrFields: { withCredentials: true },
headers: { 'X-CSRFToken': csrf_token },
});
}
function addProduct(tikette) { function addProduct(tikette) {
const zett = tikette; const zett = tikette;
const appbody = $("#appbody"); const appbody = $("#appbody");
@ -38,7 +60,7 @@ function addProduct(tikette) {
loader.show(); loader.show();
$('.btn').addClass("disabled"); $('.btn').addClass("disabled");
$.post(backend_api + 'generate', JSON.stringify(req)) post(backend_api + 'generate', req)
.then(data => { .then(data => {
const pdfbtn = $(`<a class="btn" href="${backend_api}data/${data.file}" target="_blank">open pdf</a>`); const pdfbtn = $(`<a class="btn" href="${backend_api}data/${data.file}" target="_blank">open pdf</a>`);
action.append(pdfbtn); action.append(pdfbtn);
@ -58,7 +80,7 @@ function addProduct(tikette) {
const req = { const req = {
id: zett.id, id: zett.id,
}; };
$.post(backend_api + 'deletetikette', JSON.stringify(req)).then(reload); post(backend_api + 'deletetikette', req).then(reload);
return false; return false;
}); });
@ -115,7 +137,7 @@ function loadAll(zetikettes) {
color, color,
ab, ab,
}; };
$.post(backend_api + 'newtikette', JSON.stringify(req)).then(reload); post(backend_api + 'newtikette', req).then(reload);
}); });
} }
@ -134,22 +156,45 @@ function konami() {
}); });
} }
async function googleCred(creds) {
const token = creds.credential;
await post(backend_api + 'signin', {token});
$('#signin-prompt').hide();
reload();
}
async function reload() { async function reload() {
try { try {
const resp = await $.ajax({ const resp = await $.ajax({
url: backend_api + 'list', url: backend_api + 'list',
timeout: 1000, timeout: 1000,
xhrFields: { withCredentials: true },
}); });
tikats = (await $.ajax({ tikats = (await $.ajax({
url: backend_api + 'categories', url: backend_api + 'categories',
timeout: 1000, timeout: 1000,
xhrFields: { withCredentials: true },
})).tikats.sort((a, b) => a.name > b.name ? 1 : -1); })).tikats.sort((a, b) => a.name > b.name ? 1 : -1);
loadAll(resp.tikettes.sort((a, b) => (a.title < b.title) ? -1 : 1)); loadAll(resp.tikettes.sort((a, b) => (a.title < b.title) ? -1 : 1));
} catch(e) { } catch(e) {
if (e.status === 403) {
$("#signin-prompt").show();
google.accounts.id.prompt(); // also display the One Tap dialog
return;
}
const appbody = $("#appbody"); const appbody = $("#appbody");
appbody.append(`<li>Could not reach backend server`); appbody.append(`<li>Could not reach backend server`);
throw e;
} }
} }
$(document).ready(reload); $(document).ready(() => {
google.accounts.id.initialize({
client_id: google_oauth_client_id,
callback: googleCred,
});
google.accounts.id.renderButton(
document.getElementById("signin-prompt"),
{ theme: "outline", size: "large" } // customization attributes
);
reload();
});