Skip to content

Commit 5b8e3f8

Browse files
committed
fix(totp): re-generate QR code between page reload, exceed timeout and manually click
1 parent aad75cb commit 5b8e3f8

9 files changed

Lines changed: 584 additions & 190 deletions

File tree

components/app/NavBar.vue

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

55
<template>
66
<nav
7-
class="bg-tertiary-darker flex justify-center md:justify-start md:pl-28 text-white items-stretch text-normal md:text-lg">
8-
<NuxtLink class="flex items-center cursor-pointer h-16 px-2 sm:px-5 hover:bg-primary-darker" href="/devices"> Thiết
9-
bị </NuxtLink>
10-
<NuxtLink v-if="hasPermission('/admin/labs:own')" class="flex items-center cursor-pointer h-16 px-2 sm:px-5 hover:bg-primary-darker" href="/admin/labs">
7+
class="bg-tertiary-darker flex justify-center md:justify-start md:pl-28 text-white items-stretch text-normal md:text-lg"
8+
>
9+
<NuxtLink
10+
class="flex items-center cursor-pointer h-16 px-2 sm:px-5 hover:bg-primary-darker"
11+
href="/devices"
12+
>
13+
Thiết bị
14+
</NuxtLink>
15+
<NuxtLink
16+
v-if="hasPermission('/admin/labs:own')"
17+
class="flex items-center cursor-pointer h-16 px-2 sm:px-5 hover:bg-primary-darker"
18+
href="/admin/labs"
19+
>
1120
Phòng thí nghiệm
1221
</NuxtLink>
13-
<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">
22+
<NuxtLink
23+
v-if="hasPermission('/borrow-return:edit')"
24+
class="flex items-center cursor-pointer h-16 px-2 sm:px-5 hover:bg-primary-darker"
25+
href="/borrow-return"
26+
>
1427
Mượn / Trả thiết bị
1528
</NuxtLink>
16-
<UserQrButton v-if="hasRole(['student', 'teacher'])" />
17-
<button class="cursor-pointer h-16 px-2.5 sm:px-5 hover:bg-primary-darker"> Bài viết </button>
18-
<button class="cursor-pointer h-16 px-2.5 sm:px-5 hover:bg-primary-darker"> Quy định </button>
19-
<button class="cursor-pointer h-16 px-2.5 sm:px-5 hover:bg-primary-darker"> Liên hệ </button>
29+
<button class="cursor-pointer h-16 px-2.5 sm:px-5 hover:bg-primary-darker">
30+
Bài viết
31+
</button>
32+
<button class="cursor-pointer h-16 px-2.5 sm:px-5 hover:bg-primary-darker">
33+
Quy định
34+
</button>
35+
<button class="cursor-pointer h-16 px-2.5 sm:px-5 hover:bg-primary-darker">
36+
Liên hệ
37+
</button>
2038
</nav>
2139
</template>

components/app/UserAvatar.vue

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,27 @@
11
<script setup lang="ts">
2-
import { ChevronDown } from 'lucide-vue-next';
3-
4-
const { data, signOut } = useAuth();;
5-
const { hasPermission } = usePermission();
2+
import { ChevronDown } from "lucide-vue-next";
63
4+
const { data, signOut } = useAuth();
5+
const { hasPermission, hasRole } = usePermission();
76
</script>
87

98
<template>
109
<div class="relative">
1110
<DropdownMenu>
1211
<DropdownMenuTrigger>
1312
<div class="flex items-center cursor-pointer">
14-
<div class="h-9 w-9 rounded-full border-slate-dark border-[2px] bg-primary-lighter relative">
13+
<div
14+
class="h-9 w-9 rounded-full border-slate-dark border-[2px] bg-primary-lighter relative"
15+
>
1516
<img
16-
class="h-[100%] aspect-auto inline-block rounded-full" :src="data?.user?.image || ''"
17-
alt="User's avatar">
17+
class="h-[100%] aspect-auto inline-block rounded-full"
18+
:src="data?.user?.image || ''"
19+
alt="User's avatar"
20+
/>
1821
<div
19-
class="w-3 h-3 absolute bg-safe-darker rounded-full z-50 border-white border-[2px] top-6 right-[-2px]"
20-
aria-hidden />
22+
class="w-3 h-3 absolute bg-safe-darker rounded-full z-50 border-white border-[2px] top-6 right-[-2px]"
23+
aria-hidden
24+
/>
2125
</div>
2226
<ChevronDown class="h-4 w-4 ml-2 text-gray-500" :stroke-width="3" />
2327
</div>
@@ -26,21 +30,37 @@ class="w-3 h-3 absolute bg-safe-darker rounded-full z-50 border-white border-[2p
2630
<DropdownMenuContent class="w-56">
2731
<div class="flex items-center p-2 border-b">
2832
<div class="h-8 w-8 rounded-full bg-gray-200 mr-2">
29-
<img :src="data?.user?.image || ''" class="h-full w-full rounded-full" alt="User's avatar">
33+
<img
34+
:src="data?.user?.image || ''"
35+
class="h-full w-full rounded-full"
36+
alt="User's avatar"
37+
/>
3038
</div>
3139
<div class="flex flex-col">
3240
<span class="text-md font-medium">{{ data?.user?.name }}</span>
33-
<span class="text-normal text-gray-500">{{ data?.user?.roles.map(role => role.name).join(', ') }}</span>
41+
<span class="text-normal text-gray-500">{{
42+
data?.user?.roles.map((role) => role.name).join(", ")
43+
}}</span>
3444
</div>
3545
</div>
3646

37-
<NuxtLink v-if="hasPermission('/settings/users:own')" href="/settings/users">
47+
<DropdownMenuItem v-if="hasRole(['student', 'teacher'])" as-child>
48+
<UserQrButton class="w-full h-auto px-2 hover:bg-transparent" />
49+
</DropdownMenuItem>
50+
51+
<NuxtLink
52+
v-if="hasPermission('/settings/users:own')"
53+
href="/settings/users"
54+
>
3855
<DropdownMenuItem class="cursor-pointer">
3956
<span class="text-normal">Cài đặt</span>
4057
</DropdownMenuItem>
4158
</NuxtLink>
4259

43-
<DropdownMenuItem class="text-red-600 cursor-pointer hover:!bg-red-400" @click="signOut({ callbackUrl: '/' })">
60+
<DropdownMenuItem
61+
class="text-red-600 cursor-pointer hover:!bg-red-400"
62+
@click="signOut({ callbackUrl: '/' })"
63+
>
4464
<span class="text-normal">Đăng xuất</span>
4565
</DropdownMenuItem>
4666
</DropdownMenuContent>

components/app/UserQrButton.vue

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,27 @@
11
<script setup lang="ts">
2-
import { useToast } from 'vue-toastification';
2+
import { useRouter } from "vue-router";
3+
import { useToast } from "vue-toastification";
34
5+
const router = useRouter();
46
const { userId } = usePermission();
5-
const showQrModal = ref(false);
67
7-
function openQrModal() {
8+
function handleClick() {
89
if (!userId.value) {
9-
// User not logged in, handle this case
1010
const toast = useToast();
11-
toast.error('Vui lòng đăng nhập để sử dụng tính năng này');
11+
toast.error("Vui lòng đăng nhập để sử dụng tính năng này");
1212
return;
1313
}
14-
15-
showQrModal.value = true;
14+
15+
router.push("/qr");
1616
}
1717
</script>
1818

1919
<template>
20-
<button @click="openQrModal" class="cursor-pointer h-16 px-2.5 sm:px-5 hover:bg-primary-darker flex items-center">
20+
<button
21+
@click="handleClick"
22+
class="cursor-pointer h-16 px-2.5 sm:px-5 hover:bg-primary-darker flex items-center"
23+
>
2124
<Icon aria-hidden name="i-heroicons-qr-code" class="mr-1.5" />
2225
QR của tôi
2326
</button>
24-
25-
<OneTimeQrModal
26-
:is-open="showQrModal"
27-
:user-id="userId || ''"
28-
@close="showQrModal = false"
29-
/>
3027
</template>

composables/useOneTimeQrCode.ts

Lines changed: 64 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import QRCode from 'qrcode';
2-
import { ref, onUnmounted } from 'vue';
2+
import { useToast } from 'vue-toastification';
3+
4+
const toast = useToast();
35

4-
// TOTP configuration
56
const TOTP_CONFIG = {
67
digits: 6,
7-
timeStep: 30, // seconds
8+
timeStep: 60,
89
};
910

1011
export function useOneTimeQrCode() {
@@ -14,16 +15,10 @@ export function useOneTimeQrCode() {
1415
const isLoading = ref<boolean>(false);
1516
let intervalId: NodeJS.Timeout | null = null;
1617

17-
/**
18-
* Convert string to Uint8Array
19-
*/
2018
const stringToBytes = (str: string): Uint8Array => {
2119
return new TextEncoder().encode(str);
2220
};
2321

24-
/**
25-
* Generate HMAC using Web Crypto API
26-
*/
2722
const generateHMAC = async (key: Uint8Array, message: Uint8Array): Promise<ArrayBuffer> => {
2823
const cryptoKey = await crypto.subtle.importKey(
2924
'raw',
@@ -46,7 +41,6 @@ export function useOneTimeQrCode() {
4641
const userSecret = secret || `LabSyncro-${userId}-${new Date().toISOString().split('T')[0]}`;
4742
const counter = Math.floor(Date.now() / 1000 / TOTP_CONFIG.timeStep);
4843

49-
// Convert counter to 8-byte buffer
5044
const counterBytes = new Uint8Array(8);
5145
let tempCounter = counter;
5246
for (let i = counterBytes.length - 1; i >= 0; i--) {
@@ -67,13 +61,11 @@ export function useOneTimeQrCode() {
6761
(hmacArray[offset + 2] << 8) |
6862
hmacArray[offset + 3];
6963

70-
// Get last n digits
7164
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');
65+
66+
return code.toString().padStart(TOTP_CONFIG.digits, "0");
7567
} catch (error) {
76-
console.error('Error generating token:', error);
68+
toast.error(`Lỗi khi tạo mã QR: ${error}`);
7769
throw error;
7870
}
7971
};
@@ -90,7 +82,7 @@ export function useOneTimeQrCode() {
9082
const currentToken = await generateToken(userId, secret);
9183
return token === currentToken;
9284
} catch (error) {
93-
console.error('Error verifying token:', error);
85+
toast.error(`Lỗi khi xác thực mã QR: ${error}`);
9486
return false;
9587
}
9688
};
@@ -102,55 +94,67 @@ export function useOneTimeQrCode() {
10294
*/
10395
const generateQrCode = async (userId: string, extraData?: Record<string, any>) => {
10496
try {
105-
isLoading.value = true;
97+
if (intervalId) {
98+
clearInterval(intervalId);
99+
intervalId = null;
100+
}
106101

107-
// Generate token
102+
isLoading.value = true;
108103
const token = await generateToken(userId);
109104

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;
105+
const now = Date.now();
106+
const newExpiryTimestamp = now + (TOTP_CONFIG.timeStep * 1000);
114107

115-
// Create data object to be encoded in QR code
116108
const qrData = JSON.stringify({
117109
token,
118110
userId,
119-
timestamp: Date.now(),
120-
expiry: expiryTimestamp.value,
111+
timestamp: now,
112+
expiry: newExpiryTimestamp,
121113
...extraData,
122114
});
123115

124-
// Generate QR code as data URL
125116
qrDataUrl.value = await QRCode.toDataURL(qrData);
117+
expiryTimestamp.value = newExpiryTimestamp;
118+
timeLeft.value = TOTP_CONFIG.timeStep;
126119

127-
// Start the countdown
128-
startCountdown();
129120
} catch (error) {
130-
console.error('Error generating QR code:', error);
121+
toast.error(`Lỗi khi tạo mã QR: ${error}`);
122+
qrDataUrl.value = '';
123+
expiryTimestamp.value = 0;
124+
timeLeft.value = 0;
131125
} finally {
132126
isLoading.value = false;
133127
}
134128
};
135129

136-
/**
137-
* Start countdown for token expiry
138-
*/
139130
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);
131+
if (intervalId) {
132+
clearInterval(intervalId);
133+
intervalId = null;
134+
}
135+
136+
if (expiryTimestamp.value > Date.now()) {
137+
const initialTimeLeft = Math.max(0, Math.floor((expiryTimestamp.value - Date.now()) / 1000));
138+
if (timeLeft.value !== initialTimeLeft) {
139+
timeLeft.value = initialTimeLeft;
152140
}
153-
}, 1000);
141+
142+
intervalId = setInterval(() => {
143+
const now = Date.now();
144+
if (now >= expiryTimestamp.value) {
145+
timeLeft.value = 0;
146+
clearInterval(intervalId!);
147+
intervalId = null;
148+
} else {
149+
const newTimeLeft = Math.max(0, Math.floor((expiryTimestamp.value - now) / 1000));
150+
if (timeLeft.value !== newTimeLeft) {
151+
timeLeft.value = newTimeLeft;
152+
}
153+
}
154+
}, 1000);
155+
} else {
156+
timeLeft.value = 0;
157+
}
154158
};
155159

156160
/**
@@ -160,43 +164,43 @@ export function useOneTimeQrCode() {
160164
*/
161165
const verifyScannedQrCode = async (scannedQrData: string): Promise<{ userId: string; extraData?: any } | null> => {
162166
try {
163-
// Parse the QR data
164167
const qrData = JSON.parse(scannedQrData);
165168
const { token, userId, timestamp, expiry, ...extraData } = qrData;
166169

167-
// Check if token has expired
168170
if (Date.now() > expiry) {
169-
console.error('Token has expired');
171+
toast.error("Mã QR đã hết hạn");
170172
return null;
171173
}
172-
173-
// Verify the token
174+
174175
const isValid = await verifyToken(token, userId);
175176
if (!isValid) {
176-
console.error('Invalid token');
177+
toast.error("Mã QR không hợp lệ hoặc đã hết hạn");
177178
return null;
178179
}
179-
180-
// Token is valid, return user ID and any extra data
180+
181181
return { userId, extraData };
182182
} catch (error) {
183-
console.error('Error verifying QR code:', error);
183+
toast.error(`Lỗi khi xác thực mã QR: ${error}`);
184184
return null;
185185
}
186186
};
187187

188-
/**
189-
* Clean up resources when component unmounts
190-
*/
191188
const cleanUp = () => {
192-
if (intervalId) clearInterval(intervalId);
189+
if (intervalId) {
190+
clearInterval(intervalId);
191+
intervalId = null;
192+
}
193193
qrDataUrl.value = '';
194194
expiryTimestamp.value = 0;
195195
timeLeft.value = 0;
196+
isLoading.value = false;
196197
};
197198

198199
onUnmounted(() => {
199-
cleanUp();
200+
if (intervalId) {
201+
clearInterval(intervalId);
202+
intervalId = null;
203+
}
200204
});
201205

202206
return {
@@ -207,6 +211,7 @@ export function useOneTimeQrCode() {
207211
verifyToken,
208212
generateQrCode,
209213
verifyScannedQrCode,
214+
startCountdown,
210215
cleanUp,
211216
};
212217
}

0 commit comments

Comments
 (0)