passerelle/passerelle/apps/qrcode/models.py

326 lines
11 KiB
Python

import binascii
import os
import uuid
from datetime import datetime, timezone
from io import BytesIO
from django.core.validators import RegexValidator
from django.db import models
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from django.template.response import TemplateResponse
from django.urls import reverse
from django.utils.dateparse import parse_datetime
from django.utils.translation import gettext_lazy as _
from nacl.signing import SigningKey
from qrcode import ERROR_CORRECT_Q, QRCode
from qrcode.image.pil import PilImage
from passerelle.base.models import BaseResource
from passerelle.utils.api import endpoint
CERTIFICATE_SCHEMA = {
'$schema': 'http://json-schema.org/draft-04/schema#',
'type': 'object',
'unflatten': True,
'additionalProperties': False,
'properties': {
'data': {
'type': 'object',
'title': _('Data to encode in the certificate'),
'additionalProperties': {'type': 'string'},
},
'validity_start': {'type': 'string', 'format': 'date-time'},
'validity_end': {'type': 'string', 'format': 'date-time'},
},
}
READER_SCHEMA = {
'$schema': 'http://json-schema.org/draft-04/schema#',
'type': 'object',
'additionalProperties': False,
'properties': {
'validity_start': {'type': 'string', 'format': 'date-time'},
'validity_end': {'type': 'string', 'format': 'date-time'},
},
}
def generate_key():
key = os.urandom(32)
return ''.join(format(x, '02x') for x in key)
UUID_PATTERN = '(?P<uuid>[0-9|a-f]{8}-[0-9|a-f]{4}-[0-9|a-f]{4}-[0-9|a-f]{4}-[0-9a-f]{12})'
class QRCodeConnector(BaseResource):
category = _('Misc')
key = models.CharField(
_('Private Key'),
max_length=64,
default=generate_key,
validators=[RegexValidator(r'[a-z|0-9]{64}', 'Key should be a 32 bytes hexadecimal string')],
)
class Meta:
verbose_name = _('QR Code')
@property
def signing_key(self):
binary_key = binascii.unhexlify(self.key)
return SigningKey(seed=binary_key)
@property
def hex_verify_key(self):
verify_key = self.signing_key.verify_key.encode()
return binascii.hexlify(verify_key).decode('utf-8')
@endpoint(
name='save-certificate',
pattern=f'^{UUID_PATTERN}?$',
example_pattern='{uuid}',
description=_('Create or update a certificate'),
post={'request_body': {'schema': {'application/json': CERTIFICATE_SCHEMA}}},
parameters={
'uuid': {
'description': _('Certificate identifier'),
'example_value': '12345678-1234-1234-1234-123456789012',
}
},
)
def save_certificate(self, request, uuid=None, post_data=None):
validity_start = parse_datetime(post_data['validity_start'])
validity_end = parse_datetime(post_data['validity_end'])
data = post_data['data']
if not uuid:
certificate = self.certificates.create(
data=data,
validity_start=validity_start,
validity_end=validity_end,
)
else:
certificate = get_object_or_404(self.certificates, uuid=uuid)
certificate.validity_start = validity_start
certificate.validity_end = validity_end
certificate.data = data
certificate.save()
return {
'data': {
'uuid': certificate.uuid,
'qrcode_url': certificate.get_qrcode_url(request),
}
}
@endpoint(
name='get-certificate',
description=_('Retrieve an existing certificate'),
pattern=f'^{UUID_PATTERN}$',
example_pattern='{uuid}',
parameters={
'uuid': {
'description': _('Certificate identifier'),
'example_value': '12345678-1234-1234-1234-123456789012',
}
},
)
def get_certificate(self, request, uuid):
certificate = get_object_or_404(self.certificates, uuid=uuid)
return {
'err': 0,
'data': {
'uuid': certificate.uuid,
'data': certificate.data,
'validity_start': certificate.validity_start.isoformat(),
'validity_end': certificate.validity_end.isoformat(),
'qrcode_url': certificate.get_qrcode_url(request),
},
}
@endpoint(
name='get-qrcode',
description=_('Get QR Code'),
pattern=f'^{UUID_PATTERN}$',
example_pattern='{uuid}',
parameters={
'uuid': {
'description': _('QRCode\'s certificate identifier'),
'example_value': '12345678-1234-1234-1234-123456789012',
}
},
)
def get_qrcode(self, request, uuid):
certificate = self.certificates.get(uuid=uuid)
qr_code = certificate.generate_qr_code()
return HttpResponse(qr_code, content_type='image/png')
@endpoint(
name='save-reader',
pattern=f'^{UUID_PATTERN}?$',
example_pattern='{uuid}',
description=_('Create or update a qrcode reader'),
post={'request_body': {'schema': {'application/json': READER_SCHEMA}}},
parameters={
'uuid': {
'description': _('QRCode reader identifier'),
'example_value': '12345678-1234-1234-1234-123456789012',
}
},
)
def save_reader(self, request, uuid=None, post_data=None):
validity_start = parse_datetime(post_data['validity_start'])
validity_end = parse_datetime(post_data['validity_end'])
if not uuid:
reader = self.readers.create(
validity_start=validity_start,
validity_end=validity_end,
)
else:
reader = get_object_or_404(self.readers, uuid=uuid)
reader.validity_start = validity_start
reader.validity_end = validity_end
reader.save()
return {
'data': {
'uuid': reader.uuid,
'url': reader.get_url(request),
}
}
@endpoint(
name='get-reader',
description=_('Get informations about a QRCode reader'),
pattern=f'^{UUID_PATTERN}$',
example_pattern='{uuid}',
parameters={
'uuid': {
'description': _('QRCode reader identifier'),
'example_value': '12345678-1234-1234-1234-123456789012',
}
},
)
def get_reader(self, request, uuid):
reader = get_object_or_404(self.readers, uuid=uuid)
return {
'err': 0,
'data': {
'uuid': reader.uuid,
'validity_start': reader.validity_start.isoformat(),
'validity_end': reader.validity_end.isoformat(),
'url': reader.get_url(request),
},
}
@endpoint(
name='open-reader',
perm='OPEN',
description=_('Open a QRCode reader page.'),
pattern=f'^{UUID_PATTERN}$',
example_pattern='{uuid}',
parameters={
'uuid': {
'description': _('QRCode reader identifier'),
'example_value': '12345678-1234-1234-1234-123456789012',
}
},
)
def open_reader(self, request, uuid):
reader = get_object_or_404(self.readers, uuid=uuid)
now = datetime.now(timezone.utc)
return TemplateResponse(
request,
'qrcode/qrcode-reader.html',
context={
'started': now >= reader.validity_start,
'expired': now >= reader.validity_end,
'verify_key': self.hex_verify_key,
'reader': reader,
},
)
def encode_mime_like(data):
msg = ''
for key, value in data.items():
msg += '%s: %s\n' % (key, value.replace('\n', '\n '))
return msg.encode()
BASE45_CHARSET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:'
BASE45_DICT = {v: i for i, v in enumerate(BASE45_CHARSET)}
def b45encode(buf: bytes) -> bytes:
"""Convert bytes to base45-encoded string"""
res = ''
buflen = len(buf)
for i in range(0, buflen & ~1, 2):
x = (buf[i] << 8) + buf[i + 1]
e, x = divmod(x, 45 * 45)
d, c = divmod(x, 45)
res += BASE45_CHARSET[c] + BASE45_CHARSET[d] + BASE45_CHARSET[e]
if buflen & 1:
d, c = divmod(buf[-1], 45)
res += BASE45_CHARSET[c] + BASE45_CHARSET[d]
return res.encode()
class Certificate(models.Model):
uuid = models.UUIDField(verbose_name=_('UUID'), unique=True, default=uuid.uuid4)
validity_start = models.DateTimeField(verbose_name=_('Validity Start Date'))
validity_end = models.DateTimeField(verbose_name=_('Validity End Date'))
data = models.JSONField(null=True, verbose_name='Certificate Data')
resource = models.ForeignKey(QRCodeConnector, on_delete=models.CASCADE, related_name='certificates')
def generate_qr_code(self):
data = {
'uuid': str(self.uuid),
'validity_start': str(self.validity_start.timestamp()),
'validity_end': str(self.validity_end.timestamp()),
} | self.data
msg = encode_mime_like(data)
signed = self.resource.signing_key.sign(msg)
qr_code = QRCode(image_factory=PilImage, error_correction=ERROR_CORRECT_Q)
qr_code.add_data(b45encode(signed).decode())
qr_code.make(fit=True)
image = qr_code.make_image(fill_color='black', back_color='white')
fd = BytesIO()
image.save(fd)
return fd.getvalue()
def get_qrcode_url(self, request):
qrcode_relative_url = reverse(
'generic-endpoint',
kwargs={
'slug': self.resource.slug,
'connector': self.resource.get_connector_slug(),
'endpoint': 'get-qrcode',
'rest': str(self.uuid),
},
)
return request.build_absolute_uri(qrcode_relative_url)
class Reader(models.Model):
uuid = models.UUIDField(verbose_name=_('UUID'), unique=True, default=uuid.uuid4)
validity_start = models.DateTimeField(verbose_name=_('Validity Start Date'))
validity_end = models.DateTimeField(verbose_name=_('Validity End Date'))
resource = models.ForeignKey(QRCodeConnector, on_delete=models.CASCADE, related_name='readers')
def get_url(self, request):
relative_url = reverse(
'generic-endpoint',
kwargs={
'slug': self.resource.slug,
'connector': self.resource.get_connector_slug(),
'endpoint': 'open-reader',
'rest': str(self.uuid),
},
)
return request.build_absolute_uri(relative_url)