Skip to content

Commit 592115d

Browse files
committed
feat(auth): add one-time QR code
1 parent 1c07741 commit 592115d

10 files changed

Lines changed: 456 additions & 10 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ build/
66
.cache/
77
.env
88
zapatos/
9+
.DS_Store

bun.lockb

7.58 KB
Binary file not shown.

components/app/NavBar.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
const { hasPermission } = usePermission();
2+
const { hasPermission, hasRole } = usePermission();
33
</script>
44

55
<template>
@@ -13,6 +13,7 @@ const { hasPermission } = usePermission();
1313
<NuxtLink v-if="hasPermission('/borrow-return:edit')" class="flex items-center cursor-pointer h-16 px-2 sm:px-5 hover:bg-primary-darker" href="/borrow-return">
1414
Mượn / Trả thiết bị
1515
</NuxtLink>
16+
<UserQrButton v-if="hasRole(['student', 'teacher'])" />
1617
<button class="cursor-pointer h-16 px-2.5 sm:px-5 hover:bg-primary-darker"> Bài viết </button>
1718
<button class="cursor-pointer h-16 px-2.5 sm:px-5 hover:bg-primary-darker"> Quy định </button>
1819
<button class="cursor-pointer h-16 px-2.5 sm:px-5 hover:bg-primary-darker"> Liên hệ </button>

components/app/OneTimeQrModal.vue

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<script setup lang="ts">
2+
import { onMounted, watch } from 'vue';
3+
import { useToast } from 'vue-toastification';
4+
5+
const props = defineProps<{
6+
userId: string;
7+
isOpen: boolean;
8+
}>();
9+
10+
const emit = defineEmits<{
11+
(e: 'close'): void;
12+
}>();
13+
14+
const { qrDataUrl, timeLeft, isLoading, generateQrCode, cleanUp } = useOneTimeQrCode();
15+
16+
watch(() => props.isOpen, (isOpen) => {
17+
if (isOpen) {
18+
if (props.userId) {
19+
generateQrCode(props.userId);
20+
} else {
21+
emit('close');
22+
const toast = useToast();
23+
toast.error('Không có ID người dùng. Vui lòng đăng nhập và thử lại.');
24+
}
25+
} else {
26+
cleanUp();
27+
}
28+
});
29+
30+
watch(() => props.userId, (userId) => {
31+
if (props.isOpen && userId) {
32+
generateQrCode(userId);
33+
}
34+
});
35+
36+
watch(() => timeLeft.value, (newTime) => {
37+
if (newTime === 0 && props.isOpen) {
38+
// Automatically regenerate when timer reaches 0
39+
regenerateQrCode();
40+
}
41+
});
42+
43+
function regenerateQrCode() {
44+
if (props.userId) {
45+
generateQrCode(props.userId);
46+
}
47+
}
48+
49+
onMounted(() => {
50+
if (props.isOpen) {
51+
if (props.userId) {
52+
generateQrCode(props.userId);
53+
} else {
54+
emit('close');
55+
const toast = useToast();
56+
toast.error('Không có ID người dùng. Vui lòng đăng nhập và thử lại.');
57+
}
58+
}
59+
});
60+
</script>
61+
62+
<template>
63+
<Dialog :open="isOpen" @close="$emit('close')">
64+
<DialogContent class="sm:max-w-md">
65+
<DialogHeader>
66+
<DialogTitle>Mã QR dùng một lần</DialogTitle>
67+
<DialogDescription>
68+
Quét mã QR này để xác thực danh tính của bạn. Mã QR này chỉ có hiệu lực trong {{ timeLeft }} giây.
69+
</DialogDescription>
70+
</DialogHeader>
71+
<div class="flex flex-col items-center justify-center gap-4 p-4">
72+
<div v-if="isLoading" class="flex items-center justify-center w-64 h-64">
73+
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
74+
</div>
75+
<div v-else-if="qrDataUrl" class="border-2 border-gray-200 p-2">
76+
<img :src="qrDataUrl" alt="QR Code" class="w-64 h-64" />
77+
</div>
78+
<div v-else class="text-center text-red-500">
79+
Không thể tạo mã QR. Vui lòng thử lại.
80+
</div>
81+
82+
<div class="flex items-center gap-2">
83+
<div class="w-full bg-gray-200 rounded-full h-2.5">
84+
<div
85+
class="bg-primary h-2.5 rounded-full"
86+
:style="{ width: `${(timeLeft / 30) * 100}%` }"
87+
></div>
88+
</div>
89+
<span class="text-sm text-gray-500">{{ timeLeft }}s</span>
90+
</div>
91+
92+
<Button
93+
@click="regenerateQrCode"
94+
class="w-full"
95+
:disabled="isLoading"
96+
>
97+
<Icon v-if="isLoading" name="i-heroicons-arrow-path" class="w-4 h-4 mr-2 animate-spin" />
98+
<Icon v-else name="i-heroicons-arrow-path" class="w-4 h-4 mr-2" />
99+
Tạo lại mã QR
100+
</Button>
101+
</div>
102+
<DialogFooter class="sm:justify-center">
103+
<Button variant="outline" @click="$emit('close')">
104+
Đóng
105+
</Button>
106+
</DialogFooter>
107+
</DialogContent>
108+
</Dialog>
109+
</template>

components/app/UserQrButton.vue

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<script setup lang="ts">
2+
import { useToast } from 'vue-toastification';
3+
4+
const { userId } = usePermission();
5+
const showQrModal = ref(false);
6+
7+
function openQrModal() {
8+
if (!userId.value) {
9+
// User not logged in, handle this case
10+
const toast = useToast();
11+
toast.error('Vui lòng đăng nhập để sử dụng tính năng này');
12+
return;
13+
}
14+
15+
showQrModal.value = true;
16+
}
17+
</script>
18+
19+
<template>
20+
<button @click="openQrModal" class="cursor-pointer h-16 px-2.5 sm:px-5 hover:bg-primary-darker flex items-center">
21+
<Icon aria-hidden name="i-heroicons-qr-code" class="mr-1.5" />
22+
QR của tôi
23+
</button>
24+
25+
<OneTimeQrModal
26+
:is-open="showQrModal"
27+
:user-id="userId || ''"
28+
@close="showQrModal = false"
29+
/>
30+
</template>

composables/useOneTimeQrCode.ts

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import QRCode from 'qrcode';
2+
import { ref, onUnmounted } from 'vue';
3+
4+
// TOTP configuration
5+
const TOTP_CONFIG = {
6+
digits: 6,
7+
timeStep: 30, // seconds
8+
};
9+
10+
export function useOneTimeQrCode() {
11+
const qrDataUrl = ref<string>('');
12+
const expiryTimestamp = ref<number>(0);
13+
const timeLeft = ref<number>(0);
14+
const isLoading = ref<boolean>(false);
15+
let intervalId: NodeJS.Timeout | null = null;
16+
17+
/**
18+
* Convert string to Uint8Array
19+
*/
20+
const stringToBytes = (str: string): Uint8Array => {
21+
return new TextEncoder().encode(str);
22+
};
23+
24+
/**
25+
* Generate HMAC using Web Crypto API
26+
*/
27+
const generateHMAC = async (key: Uint8Array, message: Uint8Array): Promise<ArrayBuffer> => {
28+
const cryptoKey = await crypto.subtle.importKey(
29+
'raw',
30+
key,
31+
{ name: 'HMAC', hash: 'SHA-256' },
32+
false,
33+
['sign']
34+
);
35+
return crypto.subtle.sign('HMAC', cryptoKey, message);
36+
};
37+
38+
/**
39+
* Generates a time-based one-time password token for a user
40+
* @param userId The ID of the user
41+
* @param secret An optional secret. If not provided, one will be generated based on the userId
42+
* @returns The generated token
43+
*/
44+
const generateToken = async (userId: string, secret?: string): Promise<string> => {
45+
try {
46+
const userSecret = secret || `LabSyncro-${userId}-${new Date().toISOString().split('T')[0]}`;
47+
const counter = Math.floor(Date.now() / 1000 / TOTP_CONFIG.timeStep);
48+
49+
// Convert counter to 8-byte buffer
50+
const counterBytes = new Uint8Array(8);
51+
let tempCounter = counter;
52+
for (let i = counterBytes.length - 1; i >= 0; i--) {
53+
counterBytes[i] = tempCounter & 0xff;
54+
tempCounter >>= 8;
55+
}
56+
57+
const hmac = await generateHMAC(stringToBytes(userSecret), counterBytes);
58+
const hmacArray = new Uint8Array(hmac);
59+
60+
// Get offset from last nibble
61+
const offset = hmacArray[hmacArray.length - 1] & 0xf;
62+
63+
// Generate 4-byte code from HMAC
64+
let code =
65+
((hmacArray[offset] & 0x7f) << 24) |
66+
(hmacArray[offset + 1] << 16) |
67+
(hmacArray[offset + 2] << 8) |
68+
hmacArray[offset + 3];
69+
70+
// Get last n digits
71+
code = code % Math.pow(10, TOTP_CONFIG.digits);
72+
73+
// Pad with leading zeros if needed
74+
return code.toString().padStart(TOTP_CONFIG.digits, '0');
75+
} catch (error) {
76+
console.error('Error generating token:', error);
77+
throw error;
78+
}
79+
};
80+
81+
/**
82+
* Verify a token against expected values
83+
* @param token The token to verify
84+
* @param userId The userId that was used to generate the token
85+
* @param secret Optional secret used when generating the token
86+
* @returns True if the token is valid, false otherwise
87+
*/
88+
const verifyToken = async (token: string, userId: string, secret?: string): Promise<boolean> => {
89+
try {
90+
const currentToken = await generateToken(userId, secret);
91+
return token === currentToken;
92+
} catch (error) {
93+
console.error('Error verifying token:', error);
94+
return false;
95+
}
96+
};
97+
98+
/**
99+
* Generate a QR code containing a one-time token for a user
100+
* @param userId The ID of the user
101+
* @param extraData Optional additional data to include in the QR code
102+
*/
103+
const generateQrCode = async (userId: string, extraData?: Record<string, any>) => {
104+
try {
105+
isLoading.value = true;
106+
107+
// Generate token
108+
const token = await generateToken(userId);
109+
110+
// Calculate token expiry time
111+
const now = Math.floor(Date.now() / 1000);
112+
const step = TOTP_CONFIG.timeStep;
113+
expiryTimestamp.value = (Math.floor(now / step) + 1) * step * 1000;
114+
115+
// Create data object to be encoded in QR code
116+
const qrData = JSON.stringify({
117+
token,
118+
userId,
119+
timestamp: Date.now(),
120+
expiry: expiryTimestamp.value,
121+
...extraData,
122+
});
123+
124+
// Generate QR code as data URL
125+
qrDataUrl.value = await QRCode.toDataURL(qrData);
126+
127+
// Start the countdown
128+
startCountdown();
129+
} catch (error) {
130+
console.error('Error generating QR code:', error);
131+
} finally {
132+
isLoading.value = false;
133+
}
134+
};
135+
136+
/**
137+
* Start countdown for token expiry
138+
*/
139+
const startCountdown = () => {
140+
// Clear any existing interval
141+
if (intervalId) clearInterval(intervalId);
142+
143+
// Update time left on interval
144+
intervalId = setInterval(() => {
145+
const now = Date.now();
146+
if (now >= expiryTimestamp.value) {
147+
// Token expired, regenerate
148+
timeLeft.value = 0;
149+
if (intervalId) clearInterval(intervalId);
150+
} else {
151+
timeLeft.value = Math.floor((expiryTimestamp.value - now) / 1000);
152+
}
153+
}, 1000);
154+
};
155+
156+
/**
157+
* Handle a scanned QR code to verify the token inside
158+
* @param scannedQrData The data from the scanned QR code
159+
* @returns The user ID and any extra data if verification succeeds, null otherwise
160+
*/
161+
const verifyScannedQrCode = async (scannedQrData: string): Promise<{ userId: string; extraData?: any } | null> => {
162+
try {
163+
// Parse the QR data
164+
const qrData = JSON.parse(scannedQrData);
165+
const { token, userId, timestamp, expiry, ...extraData } = qrData;
166+
167+
// Check if token has expired
168+
if (Date.now() > expiry) {
169+
console.error('Token has expired');
170+
return null;
171+
}
172+
173+
// Verify the token
174+
const isValid = await verifyToken(token, userId);
175+
if (!isValid) {
176+
console.error('Invalid token');
177+
return null;
178+
}
179+
180+
// Token is valid, return user ID and any extra data
181+
return { userId, extraData };
182+
} catch (error) {
183+
console.error('Error verifying QR code:', error);
184+
return null;
185+
}
186+
};
187+
188+
/**
189+
* Clean up resources when component unmounts
190+
*/
191+
const cleanUp = () => {
192+
if (intervalId) clearInterval(intervalId);
193+
qrDataUrl.value = '';
194+
expiryTimestamp.value = 0;
195+
timeLeft.value = 0;
196+
};
197+
198+
onUnmounted(() => {
199+
cleanUp();
200+
});
201+
202+
return {
203+
qrDataUrl,
204+
timeLeft,
205+
isLoading,
206+
generateToken,
207+
verifyToken,
208+
generateQrCode,
209+
verifyScannedQrCode,
210+
cleanUp,
211+
};
212+
}

0 commit comments

Comments
 (0)