DEV Community

Cover image for Building a Secure QR Scanner with Geofencing in Vue.js
Navnit Rai
Navnit Rai

Posted on

Building a Secure QR Scanner with Geofencing in Vue.js

In today's mobile-first world, QR code scanning has become an essential feature for many applications, particularly in healthcare settings. In this post, I'll walk through how I built a secure QR scanner component for a hospital staff application that includes geofencing capabilities to ensure scanning only occurs within authorized locations.

The Challenge
The requirements for this scanner were specific:

  • Only work when staff are physically within the hospital premises
  • Provide both automatic QR scanning and manual UHID entry
  • Include flash capability for low-light conditions
  • Validate scanned data against backend systems
  • Maintain security throughout the process

Solution Architecture

  • The component uses several key technologies:
  • Vue 3 with Composition API for reactive UI
  • qr-scanner library for efficient QR code detection
  • Google Geolocation API for accurate positioning
  • Vuetify for Material Design UI components
  • Pinia for state management

1. Geofencing Implementation

async checkGeofence() {
  this.isLocating = true;
  this.locationError = 'Checking your location, please wait...';

  let userCoords = await this.getAccurateLocation();

  // Fallback to browser geolocation if Google API fails
  if (!userCoords && navigator.geolocation) {
    userCoords = await this.getBrowserLocation();
  }

  if (!userCoords) {
    this.handleLocationError();
    return;
  }

  const distance = this.calculateDistance(this.hospitalLocation, userCoords);

  if (distance <= this.allowedRadius) {
    this.enableScanner();
  } else {
    this.disableScanner(distance);
  }

  this.isLocating = false;
}
Enter fullscreen mode Exit fullscreen mode

The geofencing uses the Haversine formula to calculate distance between coordinates:

getDistance(coords1, coords2) {
  const R = 6371e3; // Earth radius in meters
  const φ1 = (coords1.lat * Math.PI) / 180;
  const φ2 = (coords2.lat * Math.PI) / 180;
  const Δφ = ((coords2.lat - coords1.lat) * Math.PI) / 180;
  const Δλ = ((coords2.lon - coords1.lon) * Math.PI) / 180;

  const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) + 
            Math.cos(φ1) * Math.cos(φ2) * 
            Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
  const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));

  return R * c;
}
Enter fullscreen mode Exit fullscreen mode

2. QR Scanner Implementation
The scanner initializes with optimal settings for healthcare use:

initScanner() {
  this.scannerRef = new QrScanner(
    this.$refs.videoRef,
    (result) => {
      const decodedData = this.decodeBase64(result.data);
      if (decodedData) {
        this.processScanResult(decodedData);
      }
    },
    {
      highlightScanRegion: true,
      preferredCamera: 'environment',
      maxScansPerSecond: 1,
      returnDetailedScanResult: true
    }
  );

  this.scannerRef.start().catch(this.handleCameraError);
}
Enter fullscreen mode Exit fullscreen mode

3. Manual Entry Fallback
For cases where QR scanning isn't possible, we provide a manual entry option:

<v-fade-transition>
  <div v-if="showManualEntry" class="w-100 mt-2 d-flex align-center">
    <v-text-field
      v-model="manualUhid"
      label="Enter UHID"
      variant="outlined"
      class="w-100"
      rounded="xl"
      density="compact"
      hide-details
    />
    <v-btn
      @click="submitManualUhid"
      color="green"
      size="small"
      icon="mdi-check"
      class="ml-3"
      rounded="xl"
      elevation="2"
    />
  </div>
</v-fade-transition>
Enter fullscreen mode Exit fullscreen mode

Security Considerations

  • Location Verification: Ensures scanning only occurs within hospital premises
  • Base64 Decoding: Safely decodes and parses QR content
  • Camera Permissions: Handles permission errors gracefully
  • Data Validation: All scanned data is validated against backend systems ** Challenges and Solutions** Challenge: **Accurate location detection on mobile devices **Solution: Implemented a hybrid approach using Google's Geolocation API with browser geolocation fallback

Challenge: Camera performance on low-end devices
Solution: Limited scan rate to 1 scan/second and optimized video settings

Challenge: User experience in varying light conditions
Solution: Added flash toggle and clear visual feedback

Complete code--->

<template>
    <div>
        <div v-if="locationError" class="pa-4 text-center">
            <v-alert type="error" variant="tonal" border="start" prominent>
                {{ locationError }}
            </v-alert>
        </div>

        <div v-if="isWithinRange">
            <div class="scanner-wrapper">
                <video
                    ref="videoRef"
                    class="scanner-video rounded-xl elevation-3"
                    :style="{ border: scanActive ? '2px solid #4CAF50' : '2px solid #757575' }"
                ></video>
                <div class="scanner-frame">
                    <div class="scan-line" :class="{ 'scan-animation': scanActive }"></div>
                </div>
            </div>

            <div class="d-flex flex-column align-center pa-4 gap-4">
                <div class="d-flex justify-center align-center">
                    <v-btn
                        @click="toggleManualEntry"
                        :color="showManualEntry ? 'green' : 'secondary'"
                        rounded="xl"
                        elevation="2"
                        :text="showManualEntry ? 'Close Manual Entry' : 'Enter UHID Manually'"
                    />
                    <v-btn
                        v-if="cameraAvailable"
                        @click="toggleFlash"
                        :color="flashOn ? 'yellow' : 'grey-lighten-1'"
                        :icon="flashOn ? 'mdi-flashlight-off' : 'mdi-flashlight'"
                        class="ml-3"
                        size="small"
                        rounded="xl"
                        elevation="2"
                    />
                    <v-btn
                        color="primary"
                        @click="checkGeofence"
                        rounded="xl"
                        class="ml-2"
                        elevation="2"
                        prepend-icon="mdi-crosshairs-gps"
                        text=" Current Location"
                    />
                </div>
                <v-fade-transition>
                    <div v-if="showManualEntry" class="w-100 mt-2 d-flex align-center">
                        <v-text-field
                            v-model="manualUhid"
                            label="Enter UHID"
                            variant="outlined"
                            class="w-100"
                            rounded="xl"
                            density="compact"
                            hide-details
                        />
                        <v-btn
                            @click="submitManualUhid"
                            color="green"
                            size="small"
                            icon="mdi-check"
                            class="ml-3"
                            rounded="xl"
                            elevation="2"
                        />
                    </div>
                </v-fade-transition>
            </div>
        </div>
    </div>
</template>
<script>
import QrScanner from 'qr-scanner';
import { useStaffStore } from '@/store/staffStore.js';
import axios from 'axios';

export default {
    name: 'OBQRScanner',
    data() {
        return {
            scanActive: true,
            flashOn: false,
            scanResult: false,
            cameraAvailable: false,
            scannerRef: null,
            showManualEntry: false,
            manualUhid: null,
            hco_id: null,
            user_id: null,
            uhid: null,
            location_id: null,
            isLocating: false,
            visit_id: null,
            key: null,
            appointment_id: null,
            isWithinRange: false,
            locationError: 'Checking your location, please wait...',
            hospitalLocation: {
                lat: 25.9278416,
                lon: 83.6141056
            },
            allowedRadius: 300
        };
    },
    mounted() {
        this.checkGeofence();
    },
    beforeUnmount() {
        this.stopScanner();
    },
    computed: {
        user_type: () => useStaffStore().user_type || ''
    },
    methods: {
        async getAccurateLocation() {
            const GOOGLE_API_KEY = import.meta.env.VITE_GOOGLE_GEOLOCATION_API_KEY;

            try {
                const response = await axios.post(`https://www.googleapis.com/geolocation/v1/geolocate?key=${GOOGLE_API_KEY}`, {
                    considerIp: 'true'
                });
                if (response.data && response.data.location) {
                    const userCoords = {
                        lat: response.data.location.lat,
                        lon: response.data.location.lng,
                        accuracy: response.data.accuracy
                    };

                    console.log(' Google API location:', userCoords);
                    return userCoords;
                } else {
                    throw new Error('Failed to get location from Google API.');
                }
            } catch (error) {
                console.error('Google Geolocation API error:', error);
                return null;
            }
        },

        async checkGeofence() {
            this.isLocating = true;
            this.locationError = 'Checking your location, please wait...';

            let userCoords = await this.getAccurateLocation();

            // fallback
            if (!userCoords && navigator.geolocation) {
                try {
                    userCoords = await new Promise((resolve, reject) => {
                        navigator.geolocation.getCurrentPosition(
                            (pos) => resolve({ lat: pos.coords.latitude, lon: pos.coords.longitude }),
                            (err) => reject(err),
                            { enableHighAccuracy: true, timeout: 10000 }
                        );
                    });
                } catch (e) {
                    this.locationError = 'Unable to determine your location.';
                    this.isLocating = false;
                    return;
                }
            }

            if (!userCoords) {
                this.locationError = 'Location detection failed.';
                this.isLocating = false;
                return;
            }

            const distance = this.getDistance(this.hospitalLocation, userCoords);

            if (distance <= this.allowedRadius) {
                this.isWithinRange = true;
                this.locationError = null;
                this.$nextTick(() => {
                    this.initScanner();
                });
            } else {
                this.isWithinRange = false;
                this.locationError = `Scanner Disabled: You are approximately ${distance.toFixed(0)} meters away and need to be within ${
                    this.allowedRadius
                } meters of the hospital.`;
            }

            this.isLocating = false;
        },

        getDistance(coords1, coords2) {
            const R = 6371e3;
            const φ1 = (coords1.lat * Math.PI) / 180;
            const φ2 = (coords2.lat * Math.PI) / 180;
            const Δφ = ((coords2.lat - coords1.lat) * Math.PI) / 180;
            const Δλ = ((coords2.lon - coords1.lon) * Math.PI) / 180;

            const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
            const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));

            return R * c;
        },

        initScanner() {
            const videoRef = this.$refs.videoRef;

            if (!videoRef) {
                console.error('Scanner init failed: Video element not found.');
                return;
            }

            this.scannerRef = new QrScanner(
                videoRef,
                (result) => {
                    const decodedData = this.decodeBase64(result.data);
                    if (decodedData) {
                        this.parseDecodedData(decodedData);
                        this.validatePatient();
                        this.scanResult = true;
                        this.scanActive = false;
                        this.scannerRef.stop();
                    }
                },
                {
                    highlightScanRegion: true,
                    highlightCodeOutline: true,
                    preferredCamera: 'environment',
                    maxScansPerSecond: 1,
                    returnDetailedScanResult: true
                }
            );

            this.scannerRef
                .start()
                .then(() => {
                    this.cameraAvailable = true;
                })
                .catch((err) => {
                    console.error('Camera error:', err);

                    if (err === 'NotAllowedError') {
                        this.locationError = 'Camera access was denied. Please enable camera permissions in your browser settings.';
                    } else {
                        this.locationError = `Could not start camera. Error: ${err.name || err}`;
                    }
                });
        },

        stopScanner() {
            if (this.scannerRef) {
                this.scannerRef.stop();
                this.scannerRef.destroy();
                this.scannerRef = null;
            }
        },
        toggleManualEntry() {
            this.showManualEntry = !this.showManualEntry;
            if (!this.showManualEntry) {
                this.manualUhid = null;
            }
        },
        submitManualUhid() {
            if (this.manualUhid) {
                this.uhid = this.manualUhid;
                this.validatePatient();
            }
        },
        toggleFlash() {
            if (this.cameraAvailable) {
                this.scannerRef.toggleFlash().then(() => {
                    this.flashOn = !this.flashOn;
                });
            }
        },
        decodeBase64(base64Data) {
            try {
                const decoded = atob(base64Data);
                return JSON.parse(decoded);
            } catch (error) {
                console.error('Failed to decode base64 or parse JSON:', error);
                return null;
            }
        },
        parseDecodedData(decodedData) {
            this.hco_id = decodedData.hco_id;
            this.user_id = decodedData.user_id;
            this.uhid = decodedData.uhid;
            this.location_id = decodedData.location_id;
            this.visit_id = decodedData.visit_id;
            this.key = decodedData.key;
            this.appointment_id = decodedData.appointment_id || null;
        },
        async validatePatient() {
            try {
                const data = {
                    hco_id: this.hco_id,
                    user_id: this.user_id,
                    uhid: this.uhid,
                    location_id: 1,
                    visit_id: this.visit_id,
                    key: this.key,
                    event: this.user_type,
                    manualUhid: this.manualUhid,
                    appointment_id: this.appointment_id
                };

                const response = await axios.post('/staff/QrScanner.html?action=scanQR', data);

                if (response.data) {
                    this.stopScanner();
                    this.$router.go(-1);
                }
            } catch (error) {
                console.error('Error validating patient:', error);
            }
        }
    }
};
</script>

<style scoped>
.qr-scanner-container {
    max-width: 100%;
    padding: 16px;
    height: 100vh;
    display: flex;
    flex-direction: column;
}

.scanner-wrapper {
    position: relative;
    width: 100%;
    margin: 0 auto;
    max-width: 400px;
}

.scanner-video {
    width: 100%;
    height: 400px;
    display: block;
    transition: all 0.3s ease;
}

.scanner-frame {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    pointer-events: none;
}

.scan-line {
    position: absolute;
    top: 50%;

    left: 30%;
    width: 39%;
    height: 2px;
    background: rgba(76, 175, 80, 0.8);
    box-shadow: 0 0 10px rgba(76, 175, 80, 0.8);
    z-index: 1;
}

.scan-animation {
    animation: scan 2s infinite linear;
}

@keyframes scan {
    0% {
        top: 10%;
    }
    100% {
        top: 90%;
    }
}
</style>

Enter fullscreen mode Exit fullscreen mode

Top comments (0)