UNPKG

@digital-blueprint/greenlight-app

Version:

[GitHub Repository](https://github.com/digital-blueprint/greenlight-app) | [npmjs package](https://www.npmjs.com/package/@digital-blueprint/greenlight-app) | [Unpkg CDN](https://unpkg.com/browse/@digital-blueprint/greenlight-app/) | [Greenlight Bundle](ht

671 lines (586 loc) 22.3 kB
import DBPLitElement from '@dbp-toolkit/common/dbp-lit-element'; import {getStackTrace} from '@dbp-toolkit/common/error'; import {send} from '@dbp-toolkit/common/notification'; import {combineURLs} from '@dbp-toolkit/common'; import {parseGreenPassQRCode, i18nKey} from './utils'; import {defaultValidator, ValidationResult, RegionResult} from './hcert'; import {checkPerson} from './hcertmatch.js'; import {encodeAdditionalInformation} from './crypto.js'; import * as storage from './storage.js'; export default class DBPGreenlightLitElement extends DBPLitElement { constructor() { super(); this.isSessionRefreshed = false; this.auth = {}; this.person = {}; this.searchHashString = ''; this.searchSelfTestStringArray = ''; this.selfTestValid = false; this.ticketTypes = {full: 'ET'}; } _hasMultipleTicketTypes() { return this.ticketTypes['full'] !== undefined && this.ticketTypes['partial'] !== undefined; } static get properties() { return { ...super.properties, auth: {type: Object}, searchSelfTestStringArray: { type: String, attribute: 'gp-search-self-test-string-array', }, searchHashString: {type: String, attribute: 'gp-search-hash-string'}, selfTestValid: {type: Boolean, attribute: 'gp-self-test-valid'}, ticketTypes: {type: Object, attribute: 'ticket-types'}, }; } connectedCallback() { super.connectedCallback(); this._loginStatus = ''; this._loginState = []; this._loginCalled = false; } /** * Request a re-render every time isLoggedIn()/isLoading() changes */ _updateAuth() { this._loginStatus = this.auth['login-status']; let newLoginState = [this.isLoggedIn(), this.isLoading()]; if (this._loginState.toString() !== newLoginState.toString()) { this.requestUpdate(); } this._loginState = newLoginState; if (this.isLoggedIn() && !this._loginCalled && this.hasPermissions()) { this._loginCalled = true; this.loginCallback(); } } loginCallback() { // Implement in subclass } update(changedProperties) { changedProperties.forEach((oldValue, propName) => { switch (propName) { case 'auth': this._updateAuth(); break; } }); super.update(changedProperties); } /** * Returns if a person is set in or not * * @returns {boolean} true or false */ isLoggedIn() { return this.auth.person !== undefined && this.auth.person !== null; } /** * Returns true if a person has successfully logged in * * @returns {boolean} true or false */ isLoading() { if (this._loginStatus === 'logged-out') return false; return !this.isLoggedIn() && this.auth.token !== undefined; } hasPermissions() { if (!this.auth.person || !Array.isArray(this.auth.person.roles)) return false; if (this.auth.person.roles.includes('ROLE_SCOPE_GREENLIGHT')) { return true; } return false; } /** * Send a fetch to given url with given options * * @param url * @param options * @returns {object} response (error or result) */ async httpGetAsync(url, options) { let response = await fetch(url, options) .then((result) => { if (!result.ok) throw result; return result; }) .catch((error) => { return error; }); return response; } /** * Sends an analytics error event for the request of a room * * @param category * @param action * @param information * @param responseData */ async sendErrorAnalyticsEvent(category, action, information, responseData = {}) { let responseBody = {}; // Use a clone of responseData to prevent "Failed to execute 'json' on 'Response': body stream already read" // after this function, but still a TypeError will occur if .json() was already called before this function try { responseBody = await responseData.clone().json(); } catch (e) { responseBody = responseData; // got already decoded data } const data = { status: responseData.status || '', url: responseData.url || '', description: responseBody['hydra:description'] || '', errorDetails: responseBody['relay:errorDetails'] || '', information: information, // get 5 items from the stack trace stack: getStackTrace().slice(1, 6), }; this.sendSetPropertyEvent('analytics-event', { category: category, action: action, name: JSON.stringify(data), }); } /** * Sends an analytics success event for the request of a room * * @param category * @param action * @param information */ async sendSuccessAnalyticsEvent(category, action, information) { const data = { information: information, }; this.sendSetPropertyEvent('analytics-event', { category: category, action: action, name: JSON.stringify(data), }); } async sendCreateTicketRequest() { let additionalInformation; if (this._hasMultipleTicketTypes() && this.hasValidProof) { additionalInformation = this.isFullProof ? 'full' : 'partial'; } else if (!this._hasMultipleTicketTypes() && this.hasValidProof && !this.isSelfTest) { additionalInformation = 'local-proof'; } else { additionalInformation = ''; } let body = { consentAssurance: this.isConfirmChecked, //"additionalInformation": await encodeAdditionalInformation(this.auth.token, this.hasValidProof && !this.isSelfTest ? 'local-proof' : ''), additionalInformation: await encodeAdditionalInformation( this.auth.token, additionalInformation ), }; const options = { method: 'POST', headers: { 'Content-Type': 'application/ld+json', Authorization: 'Bearer ' + this.auth.token, }, body: JSON.stringify(body), }; return await this.httpGetAsync( combineURLs(this.entryPointUrl, '/greenlight/permits'), options ); } saveWrongHashAndNotify(title, body, hash, notificationType = 'danger') { send({ summary: title, body: body, type: notificationType, timeout: 5, }); if (this.wrongHash) this.wrongHash.push(hash); } formatValidUntilDate(date) { return date.toLocaleDateString('de-DE', { day: 'numeric', year: 'numeric', month: 'numeric', }); } formatValidUntilTime(date) { return date.toLocaleTimeString('de-DE', { hour: 'numeric', minute: 'numeric', }); } async checkAlreadySend(data, reset, wrongQrArray, title = '', body = '', message = '') { const i18n = this._i18n; title = title === '' ? i18n.t('acquire-3g-ticket.invalid-title') : title; body = body === '' ? i18n.t('acquire-3g-ticket.invalid-body') : body; let checkAlreadySend = await wrongQrArray.includes(data); if (checkAlreadySend) { if (!reset) { reset = true; setTimeout(function () { wrongQrArray.splice(0, wrongQrArray.length); wrongQrArray.length = 0; reset = false; }, 3000); } } else { wrongQrArray.push(data); if (!this.preCheck) { send({ summary: title, body: body, type: 'danger', timeout: 5, }); this.proofUploadFailed = true; this.message = message !== '' ? message : i18nKey('acquire-3g-ticket.invalid-qr-body'); } } } /** * wrapping function for parsing the Hashdata with the function parseGreenPassQRCode * * @param data * @param searchHashString * @returns {boolean} true if data is valid greenpass QR Code * @returns {boolean} false if data is invalid greenpass QR Code */ async tryParseHash(data, searchHashString) { try { parseGreenPassQRCode(data, searchHashString); return true; } catch (error) { return false; } } /** * Checks if a specific data is already validated before and resets the array where its saved to get * again a notification after 3 seconds * * This function is a migitation for not spaming the screen with notifications * * @param data * @returns {boolean} true if data is already checked * @returns {boolean} false if data is not checked in the last 3 seconds */ async isAlreadyChecked(data) { let alreadyChecked = false; if (this.wrongHash !== undefined) alreadyChecked = await this.wrongHash.includes(data); if (alreadyChecked) { const that = this; if (!this.resetWrongHash) { this.resetWrongHash = true; setTimeout(function () { that.wrongHash.splice(0, that.wrongHash.length); that.wrongHash.length = 0; that.resetWrongHash = false; }, 3000); } } return alreadyChecked; } async checkForValidProofLocal() { this.greenPassHash = ''; try { let hash = null; try { hash = await storage.fetch(this.auth['person-id'], this.auth['subject']); } catch (error) { console.log('checkForValidProofLocal Error', error); } if (hash !== null) { await this.checkQRCode(hash); } } finally { if (this.preCheck) this.preCheck = false; } } async checkQRCode(data) { if (await this.isAlreadyChecked(data)) return; let check = await this.tryParseHash(data, this.searchHashString); if (check) { this.greenPassHash = data; this.isSelfTest = false; this.hasValidProof = true; this.isFullProof = false; this.proofUploadFailed = false; await this.doActivation(this.greenPassHash, 'ActivationRequest', this.preCheck); return; } let selfTestURL = ''; if (this.searchSelfTestStringArray && this.searchSelfTestStringArray !== '') { const array = this.searchSelfTestStringArray.split(','); for (const selfTestString of array) { check = await this.tryParseHash(data, selfTestString); if (check) { selfTestURL = data; break; } } } if (check && selfTestURL !== '' && !this.selfTestValid) { const i18n = this._i18n; await this.checkAlreadySend( data.data, this.resetWrongQr, this.wrongQR ? this.wrongQR : [], i18n.t('self-test-not-supported-title'), i18n.t('self-test-not-supported-body'), i18n.t('self-test-not-supported-title') ); return; } if (check && selfTestURL !== '') { if (!this.preCheck) { this.message = i18nKey('acquire-3g-ticket.found-valid-selftest'); } else { this.message = i18nKey('acquire-3g-ticket.found-valid-selftest-preCheck'); } this.isSelfTest = true; this.greenPassHash = selfTestURL; if (this.showQrContainer !== undefined && this.showQrContainer !== false) { this.stopQRReader(); this.QRCodeFile = null; this.showQrContainer = false; } this.hasValidProof = true; this.proofUploadFailed = false; this.isFullProof = false; if (this._('#text-switch')) this._('#text-switch')._active = ''; this.showCreateTicket = true; if (this._('#trust-button') && this._('#trust-button').checked) { await this.encryptAndSaveHash(); } } else { if (this.wrongQR !== undefined) await this.checkAlreadySend(data.data, this.resetWrongQr, this.wrongQR); } } /** * Sends an activation request and do error handling and parsing * Include message for user when it worked or not * Saves invalid QR codes in array in this.wrongHash, so no multiple requests are send * * Possible paths: activation, invalid input, gp hash wrong * no permissions, any other errors, gp hash empty * * @param greenPassHash * @param category * @param precheck */ async doActivation(greenPassHash, category, precheck = false) { const i18n = this._i18n; this.detailedError = ''; // Error: no valid hash detected if (greenPassHash.length <= 0) { if (!precheck) { this.message = i18nKey('acquire-3g-ticket.invalid-qr-body'); this.saveWrongHashAndNotify( i18n.t('acquire-3g-ticket.invalid-title'), i18n.t('acquire-3g-ticket.invalid-body'), greenPassHash ); } return; } await this.checkActivationResponse(greenPassHash, category, precheck); } /** * Parse the response of a green pass activation request * Include message for user when it worked or not * Saves invalid QR codes in array in this.wrongHash, so no multiple requests are send * * Possible paths: activation, refresh session, invalid input, green pass hash wrong * no permissions, any other errors, green pass hash empty * * @param greenPassHash * @param category * @param preCheck */ async checkActivationResponse(greenPassHash, category, preCheck = false) { const i18n = this._i18n; let regions = Object.values(this.ticketTypes); let fullProofRegion = this.ticketTypes['full'] ?? 'ET'; let errorRegion = this.ticketTypes['partial'] ?? fullProofRegion; /** @type {ValidationResult} */ let res; try { res = await defaultValidator.validate( greenPassHash, new Date(), this.lang, 'AT', regions ); this.validationFailed = false; } catch (error) { // Validation wasn't possible (Trust data couldn't be loaded, signatures are broken etc.) console.error('ERROR:', error); await this.sendErrorAnalyticsEvent('HCertValidation', 'DataError', ''); this.validationFailed = true; this.proofUploadFailed = true; this.hasValidProof = false; this.isFullProof = false; this.detailedError = error.message; this.message = i18nKey('validation-not-possible'); this.saveWrongHashAndNotify( i18n.t('validation-not-possible-title'), i18n.t('validation-not-possible-body'), greenPassHash ); return; } // HCert has expired or is invalid if (!res.isValid) { await this.sendErrorAnalyticsEvent('HCertValidation', 'Expired', ''); this.proofUploadFailed = true; this.hasValidProof = false; this.isFullProof = false; if (!preCheck) { this.detailedError = res.error; this.saveWrongHashAndNotify( i18n.t('acquire-3g-ticket.invalid-title'), i18n.t('acquire-3g-ticket.invalid-body'), greenPassHash ); this.message = i18nKey('acquire-3g-ticket.invalid-document'); } return; } /** @type {RegionResult[]} */ let validRegions = []; for (let region of regions) { if (res.regions[region].isValid) { validRegions.push(res.regions[region]); } } // HCert has expired according ot the "ET" rules /** @type {RegionResult} */ if (!validRegions.length) { await this.sendErrorAnalyticsEvent('HCertValidation', 'Expired', ''); this.proofUploadFailed = true; this.hasValidProof = false; if (!preCheck) { // Use the less strict region for the error message to show what would be needed // to get at least something. this.detailedError = res.regions[errorRegion].error; this.saveWrongHashAndNotify( i18n.t('acquire-3g-ticket.invalid-title'), i18n.t('acquire-3g-ticket.invalid-body'), greenPassHash ); this.message = i18nKey('acquire-3g-ticket.invalid-document'); } return; } // HCert is valid if (this.auth) { // Fetch the currently logged in person let personId = this.auth['person-id']; const options = { method: 'GET', headers: { Authorization: 'Bearer ' + this.auth.token, }, }; let response = await this.httpGetAsync( combineURLs(this.entryPointUrl, '/base/people/' + encodeURIComponent(personId)), options ); let person = await response.json(); // Make sure the person matches the proof if ( !checkPerson( res.firstname, res.lastname, res.firstname_t, res.lastname_t, res.dob, person.givenName, person.familyName, person.birthDate ) ) { if (!preCheck) { this.saveWrongHashAndNotify( i18n.t('acquire-3g-ticket.invalid-title'), i18n.t('acquire-3g-ticket.invalid-body'), greenPassHash ); this.message = i18nKey('acquire-3g-ticket.not-same-person'); } this.proofUploadFailed = true; this.hasValidProof = false; this.isFullProof = false; await this.sendSuccessAnalyticsEvent('HCertValidation', 'NameDoesntMatch', '', ''); return; } } if (this._('#trust-button') && this._('#trust-button').checked) { await this.encryptAndSaveHash(); } // XXX: We use the earliest validUntil available since we can't display more of them. // Assuming the rules are a subset of each other then this doesn't change anything. let earliestValidUntil = validRegions[0].validUntil; for (let entry of validRegions) { if (entry.validUntil < earliestValidUntil) { earliestValidUntil = entry.validUntil; } } this.person.firstname = res.firstname; this.person.lastname = res.lastname; this.person.dob = res.dob; this.person.validUntil = earliestValidUntil; if (this.showQrContainer !== undefined && this.showQrContainer !== false) { this.stopQRReader(); this.QRCodeFile = null; this.showQrContainer = false; } this.hasValidProof = true; this.proofUploadFailed = false; this.isSelfTest = false; this.isFullProof = false; for (let entry of validRegions) { if (entry.region == fullProofRegion) { this.isFullProof = true; } } if (this._('#text-switch')) this._('#text-switch')._active = ''; this.showCreateTicket = true; if (preCheck) { this.message = i18nKey('acquire-3g-ticket.found-valid-3g-preCheck'); } else { this.message = i18nKey('acquire-3g-ticket.found-valid-3g'); } await this.sendSuccessAnalyticsEvent('HCertValidation', 'Success', ''); } async persistStorageMaybe() { if (navigator.storage && navigator.storage.persist) { if (await navigator.storage.persist()) console.log('Storage will not be cleared except by explicit user action'); else console.log('Storage may be cleared by the UA under storage pressure.'); } } async encryptAndSaveHash() { // We don't want to await here since it's easy for the user to miss the // permission popup in Firefox while the ticket creation is in progress, // and this also breaks e2e tests in cypress + some versions of firefox this.persistStorageMaybe(); let expiresAt; if (this.isSelfTest) { expiresAt = Date.now() + 60000 * 1440; //24 hours } await storage.save( this.greenPassHash, this.auth['person-id'], this.auth['subject'], expiresAt ); } async clearLocalStorage() { await storage.clear(this.auth['person-id']); } }