qrcode: add frontend qrcode reader (#82652)
gitea/passerelle/pipeline/head This commit looks good Details

This commit is contained in:
Corentin Sechet 2023-11-05 21:40:45 +01:00 committed by Benjamin Dauvergne
parent 7314fa224c
commit 32d3dd01bc
10 changed files with 30367 additions and 14 deletions

5
.gitignore vendored
View File

@ -12,6 +12,7 @@ passerelle.egg-info/
coverage.xml
junit-py*.xml
.sass-cache/
passerelle/static/css/style.css
passerelle/static/css/style.css.map
passerelle/**/static/**/css/style.css
passerelle/**/static/**/css/style.css.map
node_modules/
coverage/

10
README
View File

@ -126,3 +126,13 @@ django-jsonresponse (https://github.com/jjay/django-jsonresponse)
# Files: passerelle/utils/jsonresponse.py
# Copyright (c) 2012 Yasha Borevich <j.borevich@gmail.com>
# Licensed under the BSD license
tweetnacl-js (https://github.com/dchest/tweetnacl-js)
# Files: passerelle/apps/qrcode/static/qrcode/js/nacl.min.js
# Copyright: https://github.com/dchest/tweetnacl-js/blob/master/AUTHORS.md
# Licensed under the Unlicense license (public domain)
zxing-browser (https://github.com/zxing-js/browser/)
# Files: passerelle/apps/qrcode/static/qrcode/js/zxing-browser.min.js
# Copyright: (c) 2018 ZXing for JS
# Licensed under the MIT license.

View File

@ -67,6 +67,16 @@ class QRCodeConnector(BaseResource):
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}?$',
@ -208,6 +218,7 @@ class QRCodeConnector(BaseResource):
@endpoint(
name='open-reader',
perm='OPEN',
description=_('Open a QRCode reader page.'),
pattern=f'^{UUID_PATTERN}$',
example_pattern='{uuid}',
@ -227,6 +238,7 @@ class QRCodeConnector(BaseResource):
context={
'started': now >= reader.validity_start,
'expired': now >= reader.validity_end,
'verify_key': self.hex_verify_key,
'reader': reader,
},
)
@ -272,9 +284,7 @@ class Certificate(models.Model):
'validity_end': str(self.validity_end.timestamp()),
} | self.data
msg = encode_mime_like(data)
binary_key = binascii.unhexlify(self.resource.key)
signing_key = SigningKey(seed=binary_key)
signed = signing_key.sign(msg)
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)

View File

@ -0,0 +1,140 @@
$red: #C4381C;
$red-light: #FCECE8;
$green: #47752F;
$green-light: #F2F7EE;
$gray-light: #CECECE;
qrcode-reader {
height: 100%;
width: 100%;
display: grid;
}
.qrcode-reader {
&--video-wrapper {
grid-area: 1 / 1 / 2 / 2;
width: fit-content;
height: fit-content;
display: grid;
justify-self: center;
align-self: center;
}
&--video {
grid-area: 1 / 1 / 2 / 2;
justify-self: center;
align-self: center;
width: 100%;
height: auto;
max-height: 100%;
}
&--fullscreen-button {
z-index: 1;
grid-area: 1 / 1 / 2 / 2;
width: 1.8rem;
height: 1.8rem;
align-self: end;
justify-self: end;
border: none;
fill: white;
margin: 5px;
}
&--wrapper.fullscreen &--enter-fullscreen-icon {
display: none;
}
&--exit-fullscreen-icon {
display: none;
}
&--wrapper.fullscreen &--exit-fullscreen-icon {
display: block;
}
&--popup {
grid-area: 1 / 1 / 2 / 2;
align-self: end;
justify-self: center;
--title-background: #{$green-light};
--title-color: #{$green};
border: 2px solid var(--title-color);
border-radius: 5px;
margin: 10px;
box-shadow: 0 0 10px 3px #ffffff;
background: white;
display: flex;
flex-direction: column;
justify-items: center;
&.error {
--title-background: #{$red-light};
--title-color: #{$red};
}
&.closed {
display: none;
}
}
&--popup-title {
font-size: 1.2rem;
font-weight: bold;
text-align: center;
border-top-right-radius: 5px;
border-top-left-radius: 5px;
padding: 5px;
color: var(--title-color);
background-color: var(--title-background);
}
&--popup-content {
margin: 10px;
}
&--validity {
color: var(--title-color);
font-weight: bold;
display: grid;
grid-template-columns: auto 1fr;
gap: 5px;
margin-bottom: 5px;
}
&--validity-label {
align-self: end;
}
&--data-items {
display: grid;
grid-template-columns: auto 1fr;
margin-top: 5px;
padding-top: 5px;
border-top: 1px solid var(--gray-light);
}
&--data-item-label {
font-weight: bold;
justify-self: end;
}
&--data-item-value {
display: flex;
flex-wrap: wrap;
}
&--close-popup-button {
margin: 5px 10px 10px 10px;
padding: 5px;
font-size: 1.2rem;
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,269 @@
import './nacl.min.js'
import './zxing-browser.min.js'
const translations = (() => {
const i18nElement = window.document.getElementById('qrcode-reader-i18n')
if (i18nElement) {
return JSON.parse(i18nElement.innerHTML)
}
return {}
})()
function translate (key) { return translations[key] || key }
function template (innerHTML) {
const templateElement = document.createElement('template')
templateElement.innerHTML = innerHTML
return templateElement
}
const notSupportedTemplate = template(`<p>${translate('not_supported')}</p>`)
const readerTemplate = template(`
<div class="qrcode-reader--video-wrapper">
<video class="qrcode-reader--video"></video>
<div class="qrcode-reader--fullscreen-button">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="qrcode-reader--enter-fullscreen-icon">
<path d="M8 3V5H4V9H2V3H8ZM2 21V15H4V19H8V21H2ZM22 21H16V19H20V15H22V21ZM22 9H20V5H16V3H22V9Z"></path>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="qrcode-reader--exit-fullscreen-icon">
<path d="M18 7H22V9H16V3H18V7ZM8 9H2V7H6V3H8V9ZM18 17V21H16V15H22V17H18ZM8 15V21H6V17H2V15H8Z"></path>
</svg>
</div>
</div>
<div class="qrcode-reader--popup closed">
<div class="qrcode-reader--popup-title"></div>
<div class="qrcode-reader--popup-content"></div>
<button class="qrcode-reader--close-popup-button">${translate('close')}</button>
</div>
`)
const validityTemplate = template(`
<div class="qrcode-reader--validity">
<div class="qrcode-reader--validity-label">${translate('from')} :</div>
<div>{validityStart}</div>
<div>${translate('to')} :</div>
<div>{validityEnd}</div>
</div>
`)
const dataTemplate = template(`
<div class="qrcode-reader--data-items"></div>
`)
const dataItemTemplate = template(`
<span class="qrcode-reader--data-item-label">{label}&nbsp;:&nbsp;</span>
<span class="qrcode-reader--data-item-value">{value}</span>
`)
function decodeMimeLike (value) {
const chunks = value.split('\n')
const data = {}
let k = null
let v = null
for (let i = 0; i < chunks.length; i++) {
const line = chunks[i]
if (line.startsWith(' ')) {
if (k !== null) {
v += '\n' + line.slice(1)
}
} else {
if (k !== null) {
data[k] = v
k = null
v = null
}
if (line.indexOf(': ') !== -1) {
const parts = line.split(': ', 2)
k = parts[0]
v = parts[1]
}
}
}
if (k !== null) {
data[k] = v
}
return data
}
function divmod (a, b) {
let remainder = a
let quotient = 0
if (a >= b) {
remainder = a % b
quotient = (a - remainder) / b
}
return [quotient, remainder]
}
const BASE45_CHARSET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:'
function decodeBase45 (str) {
const output = []
const buf = []
for (let i = 0, length = str.length; i < length; i++) {
const j = BASE45_CHARSET.indexOf(str[i])
if (j < 0) { throw new Error('Base45 decode: unknown character') }
buf.push(j)
}
for (let i = 0, length = buf.length; i < length; i += 3) {
const x = buf[i] + buf[i + 1] * 45
if (length - i >= 3) {
const [d, c] = divmod(x + buf[i + 2] * 45 * 45, 256)
output.push(d)
output.push(c)
} else {
output.push(x)
}
}
return new Uint8Array(output)
}
class QRCodeReader extends window.HTMLElement {
#popup
#popupContent
#popupTitle
constructor () {
super()
if (!this.#supported()) {
this.appendChild(notSupportedTemplate.content.cloneNode(true))
return
}
this.appendChild(readerTemplate.content.cloneNode(true))
this.#popup = this.querySelector('.qrcode-reader--popup')
this.#popupContent = this.querySelector('.qrcode-reader--popup-content')
this.#popupTitle = this.querySelector('.qrcode-reader--popup-title')
const closePopupButton = this.querySelector('.qrcode-reader--close-popup-button')
closePopupButton.addEventListener('click', () => {
this.#popup.classList.add('closed')
})
const fullScreenButton = this.querySelector('.qrcode-reader--fullscreen-button')
fullScreenButton.addEventListener('click', () => {
this.#toggleFullScreen()
})
}
connectedCallback () {
if (!this.#supported()) {
return
}
this.#startScan()
}
async #startScan () {
const codeReader = new window.ZXingBrowser.BrowserQRCodeReader()
const videoElement = this.querySelector('.qrcode-reader--video')
await codeReader.decodeFromVideoDevice(undefined, videoElement, (result) => {
if (result) {
this.#showResult(result.text)
}
})
}
get #verifyKey () {
const hexKey = this.getAttribute('verify-key')
return new Uint8Array(hexKey.match(/[\da-f]{2}/gi).map(h => parseInt(h, 16)))
}
#supported () {
return !!navigator.mediaDevices
}
#showResult (qrCodeContent) {
this.#popup.classList.remove('error')
this.#popup.classList.remove('closed')
let signed
try {
signed = decodeBase45(qrCodeContent)
} catch (error) {
this.#showError(translate('invalid_qrcode'))
return
}
const opened = window.nacl.sign.open(signed, this.#verifyKey)
if (opened == null) {
this.#showError(translate('invalid_signature'))
return
}
this.#popupContent.innerHTML = ''
const decoder = new TextDecoder('utf-8')
const decoded = decoder.decode(opened)
const data = decodeMimeLike(decoded)
delete data.uuid
const validityStart = new Date(parseFloat(data.validity_start) * 1000)
delete data.validity_start
const validityEnd = new Date(parseFloat(data.validity_end) * 1000)
delete data.validity_end
const now = new Date()
if (now.getTime() < validityStart.getTime()) {
this.#popupTitle.innerText = translate('not_yet_valid')
this.#popup.classList.add('error')
} else if (now.getTime() > validityEnd.getTime()) {
this.#popupTitle.innerText = translate('expired')
this.#popup.classList.add('error')
} else {
this.#popupTitle.innerText = translate('valid')
}
const validityElement = validityTemplate.cloneNode(true)
validityElement.innerHTML = validityElement.innerHTML
.replace('{validityStart}', validityStart.toLocaleString())
.replace('{validityEnd}', validityEnd.toLocaleString())
this.#popupContent.append(validityElement.content)
const dataElement = dataTemplate.cloneNode(true)
const dataItems = dataElement.content.querySelector('.qrcode-reader--data-items')
for (const [key, value] of Object.entries(data)) {
const dataItem = dataItemTemplate.cloneNode(true)
dataItem.innerHTML = dataItem.innerHTML.replace('{label}', key).replace('{value}', value)
dataItems.append(dataItem.content)
}
this.#popupContent.append(dataElement.content)
}
#showError (message) {
this.#popup.classList.remove('closed')
this.#popup.classList.add('error')
this.#popupTitle.innerText = translate('invalid_title')
this.#popupContent.innerText = message
}
#toggleFullScreen () {
if (document.fullscreenElement) {
document.exitFullscreen()
this.classList.remove('fullscreen')
} else {
this.requestFullscreen()
this.classList.add('fullscreen')
}
}
}
window.customElements.define('qrcode-reader', QRCodeReader)

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +1,36 @@
{% load i18n %}
{% load static %}
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<script type="application/json" id="qrcode-reader-i18n">
{
"close": "{% trans 'Close' %}",
"expired": "{% trans 'QR code Expired' %}",
"from": "{% trans 'From' %}",
"invalid_content": "{% trans 'This QR code isn\'t supported by this application.' %}",
"invalid_signature": "{% trans 'Signature verification failed.' %}",
"invalid_title": "Invalid QR Code",
"not_supported": "{% trans 'QR code reader isn\'t supported on your platform. Please update your browser.' %}",
"not_yet_valid": "{% trans 'QR code not yet valid' %}",
"to": "{% trans 'To' %}",
"valid": "{% trans 'Valid QR code' %}"
}
</script>
<link rel="stylesheet" href="{% static 'qrcode/css/style.css' %}">
<script type="module" src="{% static 'qrcode/js/qrcode-reader.js' %}"></script>
</head>
<style>
</style>
<body>
{% if not started %}
{% trans "Reader isn't usable yet." %}
{% elif expired %}
{% trans "Reader has expired." %}
{% else %}
{{ reader.validity_start }}<br />
{{ now }}
{{ reader.uuid }}
<qrcode-reader verify-key="{{ verify_key }}">
</qrcode-reader>
{% endif %}
</body>
</html>

View File

@ -1,6 +1,205 @@
import { expect, test} from 'vitest'
import 'qrcode/qrcode-reader.js'
import { expect, test, vi} from 'vitest'
import ZXingBrowser from 'qrcode/zxing-browser.min.js'
import nacl from 'qrcode/nacl.min.js'
test('test qrcode', async () => {
expect(true).toBe(true)
const okCodeData =
'SVF.WB899RLB0%7NFKM.IWSBLTVZ65OQKD59REJ+ I/IDL960%HL%F 5EVVK397HQGIUK3OAPOP0RF/X' +
'L1FIKJG08J5 LE.G9%EPEDUF7M.C%47SW64DC4W5NF6$CC1S64VCBW53ECQZCGPCSG6C%6YZC:DC-96N' +
'E1AECPED-EDLFF QEGEC9VE634L%6 47%47C%6446D36K/EXVDAVCRWEV2C0/DUF7:967461R67:6OA7' +
'Y$5DE1HEC1WE..DF$DUF7/B8-ED:JCSTDR.C4LE1WE..DF$DUF771904E93DKOEXVDKPCF/DV CP9EIE' +
'C*ZCT34ZA8.Q6$Q6HB8A0'
const invalidCodeData = 'https://georges-abitbol.fr'
const invalidSignatureData =
'ELT7PRQKJ097ZM1TA5$EJC%NOMVL5D5QNEZUR.Q5JESA5D+GO*I16IAYJO:D4ZGU GTVIM%DHI88%CGD' +
'0W6LBKOWSKLRC$ R9%EPEDUF7OB7GPC 57Y47BX5-A6+/61S6$57CW5/Q6+TCRW6GA7 57QF6H*6C56N' +
'E1AECPED-EDLFF QEGEC9VE634L%6 47%47C%6446D36K/EXVDAVCRWEV2C0/DUF7:96646L%6946OA7' +
'Y$5DE1HEC1WE..DF$DUF70A8R.C4LE1WE..DF$DUF7VF8XVDKPCF/DV CP9EIEC*ZCO34A0'
const qrcodeReaderTest = test.extend({
mock: async ({ task }, use) => {
let resolveResult = undefined
let resultPromise = new Promise((resolve) => resolveResult = resolve)
let resolveResultHandled = undefined
const terminate = Symbol()
class MockBrowserQRCodeReader {
async decodeFromVideoDevice(device, element, callback) {
while(true) {
const result = await resultPromise
if(result === terminate) {
return
}
resultPromise = new Promise((resolve) => resolveResult = resolve)
callback({ text: result })
resolveResultHandled()
}
}
}
window.nacl = nacl
navigator.mediaDevices = true
const savedZXingBrowser = window.ZXingBrowser
window.ZXingBrowser = { BrowserQRCodeReader : MockBrowserQRCodeReader }
const reader = document.createElement('qrcode-reader')
reader.setAttribute('verify-key', 'f81af42f9f9422d2393859d40994a42cdb2ef68507f056292ac96d1de1f1af83')
document.append(reader)
await use({
reader,
scan: async (text) => {
const resultHandled = new Promise((resolve) => resolveResultHandled = resolve)
resolveResult(text)
await resultHandled
}
})
vi.useFakeTimers()
reader.remove()
vi.useRealTimers ()
resolveResult(terminate)
window.ZXingBrowser = savedZXingBrowser
navigator.mediaDevices = undefined
window.nacl = undefined
}
})
test('qrcode reader shows a warning message if not supported on platform', async ({mock}) => {
const reader = document.createElement('qrcode-reader')
reader.setAttribute('verify-key', 'f81af42f9f9422d2393859d40994a42cdb2ef68507f056292ac96d1de1f1af83')
document.append(reader)
expect(reader.innerText).toBe('not_supported')
})
qrcodeReaderTest('qrcode reader shows valid qrcode informations', async ({mock}) => {
const { reader, scan } = mock
vi.setSystemTime(new Date(2023, 11, 1))
await scan(okCodeData)
const popup = reader.querySelector('.qrcode-reader--popup')
const title = popup.querySelector('.qrcode-reader--popup-title')
expect(popup.classList.contains('closed')).toBe(false)
expect(popup.classList.contains('error')).toBe(false)
expect(title.innerText).toBe('valid')
const validity = popup.querySelector('.qrcode-reader--validity')
expect(validity.innerText).toMatch(/from :\s*31\/10\/2023 23:00:00\s*to :\s*01\/12\/2023 22:59:59/)
const labels = popup.querySelectorAll('.qrcode-reader--data-item-label')
expect(labels.length).toBe(3)
expect(labels[0].innerText).toMatch('last_name')
expect(labels[1].innerText).toMatch('first_name')
expect(labels[2].innerText).toMatch('license_plate')
const values = popup.querySelectorAll('.qrcode-reader--data-item-value')
expect(values.length).toBe(3)
expect(values[0].innerText).toMatch('Abitbol')
expect(values[1].innerText).toMatch('Georges')
expect(values[2].innerText).toMatch('HA-424-AH')
const closeButton = reader.querySelector('.qrcode-reader--close-popup-button')
closeButton.dispatchEvent(new Event('click'))
expect(popup.classList.contains('closed')).toBe(true)
})
qrcodeReaderTest('qrcode reader shows error on not yet valid or expired qrcodes', async ({mock}) => {
const { reader, scan } = mock
vi.setSystemTime(new Date(2023, 9, 31)) // monthes start at 0 index, wtf javascript
await scan(okCodeData)
const popup = reader.querySelector('.qrcode-reader--popup')
const title = popup.querySelector('.qrcode-reader--popup-title')
expect(popup.classList.contains('closed')).toBe(false)
expect(popup.classList.contains('error')).toBe(true)
expect(title.innerText).toBe('not_yet_valid')
vi.setSystemTime(new Date(2023, 11, 2)) // monthes start at 0 index, wtf javascript
await scan(okCodeData)
expect(popup.classList.contains('closed')).toBe(false)
expect(popup.classList.contains('error')).toBe(true)
expect(title.innerText).toBe('expired')
})
qrcodeReaderTest('qrcode reader shows error on invalid qrcode', async ({mock}) => {
const { reader, scan } = mock
await scan(invalidCodeData)
const popup = reader.querySelector('.qrcode-reader--popup')
const title = reader.querySelector('.qrcode-reader--popup-title')
const content = reader.querySelector('.qrcode-reader--popup-content')
expect(popup.classList.contains('closed')).toBe(false)
expect(popup.classList.contains('error')).toBe(true)
expect(title.innerText).toBe('invalid_title')
expect(content.innerText).toBe('invalid_qrcode')
const closeButton = reader.querySelector('.qrcode-reader--close-popup-button')
closeButton.dispatchEvent(new Event('click'))
expect(popup.classList.contains('closed')).toBe(true)
})
qrcodeReaderTest('qrcode reader shows error on invalid signature', async ({mock}) => {
const { reader, scan } = mock
await scan(invalidSignatureData)
const popup = reader.querySelector('.qrcode-reader--popup')
const title = reader.querySelector('.qrcode-reader--popup-title')
const content = reader.querySelector('.qrcode-reader--popup-content')
expect(popup.classList.contains('closed')).toBe(false)
expect(popup.classList.contains('error')).toBe(true)
expect(title.innerText).toBe('invalid_title')
expect(content.innerText).toBe('invalid_signature')
const closeButton = reader.querySelector('.qrcode-reader--close-popup-button')
closeButton.dispatchEvent(new Event('click'))
expect(popup.classList.contains('closed')).toBe(true)
})
qrcodeReaderTest('qrcode reader can toggle fullscreen', async ({mock}) => {
const { reader } = mock
const fullscreenButton = reader.querySelector('.qrcode-reader--fullscreen-button')
reader.requestFullscreen = vi.fn()
document.exitFullscreen = vi.fn()
fullscreenButton.dispatchEvent(new Event('click'))
expect(reader.requestFullscreen).toHaveBeenCalled()
expect(document.exitFullscreen).not.toHaveBeenCalled()
expect(reader.classList.contains('fullscreen')).toBe(true)
vi.clearAllMocks()
document.fullscreenElement = reader
fullscreenButton.dispatchEvent(new Event('click'))
expect(reader.requestFullscreen).not.toHaveBeenCalled()
expect(document.exitFullscreen).toHaveBeenCalled()
expect(reader.classList.contains('fullscreen')).toBe(false)
})

View File

@ -190,7 +190,7 @@ def test_open_reader(app, connector, freezer):
freezer.move_to('2022-01-01T10:00:00')
result = app.get(f'{endpoint}/{reader.uuid}')
assert str(reader.uuid) in result.body.decode('utf-8')
assert result.pyquery(f'qrcode-reader[verify-key="{connector.hex_verify_key}"]')
freezer.move_to('2023-01-01T10:00:01')
result = app.get(f'{endpoint}/{reader.uuid}')