feat(安全): 实现反欺诈检测和强制打卡功能
- 添加反欺诈服务,定期检查设备上的黑名单应用 - 检测到黑名单应用时自动强制用户打卡下班 - 在登录后启动反欺诈检查,每5分钟执行一次 - 更新后端API支持强制打卡事件 - 调整Android最低SDK版本至24 - 添加全局事件监听处理强制打卡事件
This commit is contained in:
@@ -5,8 +5,8 @@ android {
|
|||||||
compileSdk rootProject.ext.compileSdkVersion
|
compileSdk rootProject.ext.compileSdkVersion
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "com.ouji.factory.myapp"
|
applicationId "com.ouji.factory.myapp"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdk 24
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdk 29
|
||||||
versionCode 1
|
versionCode 1
|
||||||
versionName "1.0"
|
versionName "1.0"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|||||||
+19
-15
@@ -309,72 +309,76 @@ const geofence = polygon([
|
|||||||
|
|
||||||
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
|
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return res.sendStatus(403)
|
return res.status(403).json({ message: 'Forbidden' })
|
||||||
}
|
}
|
||||||
|
|
||||||
req.user = user
|
req.user = user
|
||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
res.sendStatus(401)
|
res.status(401).json({ message: 'Unauthorized' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Worker Clock In/Out Endpoint
|
// Worker Clock In/Out Endpoint
|
||||||
app.post('/api/clock', authenticateJWT, async (req, res) => {
|
app.post('/api/clock', authenticateJWT, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { userId, eventType, qrCodeValue, latitude, longitude } = req.body
|
const { userId, eventType, qrCodeValue, latitude, longitude, notes } = req.body
|
||||||
|
|
||||||
|
// Bypass geofence and QR code validation for forced events
|
||||||
|
if (qrCodeValue !== 'FORCE_CLOCK_OUT' && qrCodeValue !== 'BLACKLIST_APP_DETECTED') {
|
||||||
// Geofencing check using the directly imported functions
|
// Geofencing check using the directly imported functions
|
||||||
const userLocation = point([longitude, latitude]);
|
const userLocation = point([longitude, latitude]);
|
||||||
const isWithinGeofence = booleanPointInPolygon(userLocation, geofence);
|
const isWithinGeofence = booleanPointInPolygon(userLocation, geofence);
|
||||||
|
|
||||||
if (!isWithinGeofence) {
|
if (!isWithinGeofence) {
|
||||||
// User is outside the geofence, log a 'failed' attempt
|
// User is outside the geofence, log a 'failed' attempt
|
||||||
// Calculate the distance from the geofence
|
|
||||||
const distance = pointToLineDistance(userLocation, geofence.geometry.coordinates[0], { units: 'meters' });
|
const distance = pointToLineDistance(userLocation, geofence.geometry.coordinates[0], { units: 'meters' });
|
||||||
// Create a descriptive note
|
|
||||||
const notes = `Clock-in outside of the zone: ${distance.toFixed(2)} meters.`;
|
const notes = `Clock-in outside of the zone: ${distance.toFixed(2)} meters.`;
|
||||||
|
|
||||||
// Insert the failed attempt into the database
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'INSERT INTO clock_records (worker_id, event_type, timestamp, qr_code_id, latitude, longitude, notes) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
'INSERT INTO clock_records (worker_id, event_type, timestamp, qr_code_id, latitude, longitude, notes) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||||
[userId, 'failed', new Date(), qrCodeValue, latitude, longitude, notes]
|
[userId, 'failed', new Date(), qrCodeValue, latitude, longitude, notes]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Return an error to the user
|
|
||||||
return res.status(403).json({ message: `You are not within the allowed work area.` });
|
return res.status(403).json({ message: `You are not within the allowed work area.` });
|
||||||
// --- MODIFICATION END ---
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [qrRows] = await db.execute('SELECT name, is_active FROM qr_codes WHERE id = ?', [
|
const [qrRows] = await db.execute('SELECT name, is_active FROM qr_codes WHERE id = ?', [
|
||||||
qrCodeValue,
|
qrCodeValue,
|
||||||
])
|
]);
|
||||||
|
|
||||||
if (qrRows.length === 0) {
|
if (qrRows.length === 0) {
|
||||||
// This code is not in the database at all.
|
return res.status(400).json({ message: 'Invalid QR Code scanned.' });
|
||||||
return res.status(400).json({ message: 'Invalid QR Code scanned.' })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!qrRows[0].is_active) {
|
if (!qrRows[0].is_active) {
|
||||||
// This code exists but has been deactivated.
|
|
||||||
return res
|
return res
|
||||||
.status(400)
|
.status(400)
|
||||||
.json({ message: 'This QR Code has expired and is no longer active.' })
|
.json({ message: 'This QR Code has expired and is no longer active.' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const [lastEventRows] = await db.execute(
|
const [lastEventRows] = await db.execute(
|
||||||
'SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1',
|
'SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1',
|
||||||
[userId],
|
[userId],
|
||||||
)
|
)
|
||||||
if (lastEventRows.length > 0 && lastEventRows[0].event_type === eventType) {
|
if (lastEventRows.length > 0 && lastEventRows[0].event_type === eventType) {
|
||||||
|
if (qrCodeValue === 'FORCE_CLOCK_OUT') {
|
||||||
|
// If it's a forced clock-out on an already clocked-out user, log it as a failed event
|
||||||
|
await db.execute(
|
||||||
|
'INSERT INTO clock_records (worker_id, event_type, timestamp, qr_code_id, latitude, longitude, notes) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[userId, 'failed', new Date(), qrCodeValue, latitude, longitude, `FAKE GPS APP Detected.`]
|
||||||
|
);
|
||||||
|
return res.status(200).json({ message: 'Forced clock-out attempt on already clocked-out user was logged.' });
|
||||||
|
}
|
||||||
return res
|
return res
|
||||||
.status(400)
|
.status(400)
|
||||||
.json({ message: `You are already clocked ${eventType === 'clock_in' ? 'in' : 'out'}.` })
|
.json({ message: `You are already clocked ${eventType === 'clock_in' ? 'in' : 'out'}.` })
|
||||||
}
|
}
|
||||||
const timestamp = new Date()
|
const timestamp = new Date()
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'INSERT INTO clock_records (worker_id, event_type, timestamp, qr_code_id, latitude, longitude) VALUES (?, ?, ?, ?, ?, ?)',
|
'INSERT INTO clock_records (worker_id, event_type, timestamp, qr_code_id, latitude, longitude, notes) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||||
[userId, eventType, timestamp, qrCodeValue, latitude, longitude],
|
[userId, eventType, timestamp, qrCodeValue, latitude, longitude, notes],
|
||||||
)
|
)
|
||||||
res.status(201).json({ message: 'Clock event recorded successfully' })
|
res.status(201).json({ message: 'Clock event recorded successfully' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ watch(
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Add app blocked event listener
|
// Add app blocked event listener
|
||||||
window.addEventListener('app-blocked', handleAppBlocked)
|
window.addEventListener('app-blocked', handleAppBlocked)
|
||||||
|
window.addEventListener('user-forced-clock-out', handleForcedClockOut)
|
||||||
|
|
||||||
// Initialize SafeArea plugin for proper safe area handling
|
// Initialize SafeArea plugin for proper safe area handling
|
||||||
if (Capacitor.isNativePlatform()) {
|
if (Capacitor.isNativePlatform()) {
|
||||||
@@ -197,9 +198,15 @@ const handleAppBlocked = (event) => {
|
|||||||
blockMessage.value = event.detail.message
|
blockMessage.value = event.detail.message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleForcedClockOut = (event) => {
|
||||||
|
isBlocked.value = true
|
||||||
|
blockMessage.value = `You have been automatically clocked out. Reason: ${event.detail.note}`
|
||||||
|
}
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
// Clean up listeners
|
// Clean up listeners
|
||||||
window.removeEventListener('app-blocked', handleAppBlocked)
|
window.removeEventListener('app-blocked', handleAppBlocked)
|
||||||
|
window.removeEventListener('user-forced-clock-out', handleForcedClockOut)
|
||||||
if (Capacitor.isNativePlatform()) {
|
if (Capacitor.isNativePlatform()) {
|
||||||
App.removeAllListeners()
|
App.removeAllListeners()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from '@capacitor/core'
|
||||||
import { apiFetch } from '@/api.js'
|
import { apiFetch } from '@/api.js'
|
||||||
|
import { authService } from './authService'
|
||||||
|
|
||||||
// Since we are using a custom plugin, we need to define it for Capacitor
|
// Since we are using a custom plugin, we need to define it for Capacitor
|
||||||
import { registerPlugin } from '@capacitor/core';
|
import { registerPlugin } from '@capacitor/core';
|
||||||
@@ -28,9 +29,21 @@ class AntiSpoofingService {
|
|||||||
console.warn('Anti-spoofing detection is only available on native platforms')
|
console.warn('Anti-spoofing detection is only available on native platforms')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.log('Anti-spoofing service initialized')
|
console.log('Anti-spoofing service initialized, but security checks are deferred until after login.')
|
||||||
// Perform a check on initialization
|
}
|
||||||
this.performSecurityCheck()
|
|
||||||
|
/**
|
||||||
|
* Start periodic security checks. This should be called after a user is authenticated.
|
||||||
|
*/
|
||||||
|
startSecurityChecks() {
|
||||||
|
if (!this.isNative) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('Starting periodic security checks...');
|
||||||
|
// Perform an immediate check
|
||||||
|
this.performSecurityCheck();
|
||||||
|
// And then check periodically (e.g., every 5 minutes)
|
||||||
|
setInterval(() => this.performSecurityCheck(), 5 * 60 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,17 +74,27 @@ class AntiSpoofingService {
|
|||||||
if (!this.isNative) {
|
if (!this.isNative) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.log('Performing security check...')
|
console.log('ANTI-SPOOFING: Performing security check...')
|
||||||
try {
|
try {
|
||||||
|
const isAuthenticated = await authService.isAuthenticated();
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
console.warn('ANTI-SPOOFING: User not authenticated, skipping security check.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('ANTI-SPOOFING: User is authenticated, proceeding with security check.');
|
||||||
|
|
||||||
const blacklist = await this.fetchBlacklistFromServer()
|
const blacklist = await this.fetchBlacklistFromServer()
|
||||||
console.log('Blacklist:', blacklist)
|
console.log('ANTI-SPOOFING: Blacklist:', blacklist)
|
||||||
const result = await AppSecurity.getInstalledApps()
|
const result = await AppSecurity.getInstalledApps()
|
||||||
const installedApps = result.packages
|
const installedApps = result.packages
|
||||||
console.log('Installed apps:', installedApps)
|
console.log('ANTI-SPOOFING: Installed apps:', installedApps)
|
||||||
|
|
||||||
for (const appPackage of installedApps) {
|
for (const appPackage of installedApps) {
|
||||||
if (blacklist.includes(appPackage)) {
|
if (blacklist.includes(appPackage)) {
|
||||||
console.warn(`Blacklisted app detected: ${appPackage}`)
|
console.warn(`ANTI-SPOOFING: Blacklisted app detected: ${appPackage}`)
|
||||||
|
|
||||||
|
// Dispatch an event to notify the UI
|
||||||
const event = new CustomEvent('app-blocked', {
|
const event = new CustomEvent('app-blocked', {
|
||||||
detail: {
|
detail: {
|
||||||
appName: appPackage,
|
appName: appPackage,
|
||||||
@@ -79,12 +102,18 @@ class AntiSpoofingService {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
window.dispatchEvent(event)
|
window.dispatchEvent(event)
|
||||||
|
|
||||||
|
// Force clock out the user with a note
|
||||||
|
console.log('ANTI-SPOOFING: Forcing clock out due to blacklisted app.');
|
||||||
|
await authService.forceClockOut('Blacklisted App Detected')
|
||||||
|
|
||||||
// Stop checking after finding one blacklisted app
|
// Stop checking after finding one blacklisted app
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
console.log('ANTI-SPOOFING: Security check complete, no blacklisted apps found.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Security check failed:', error)
|
console.error('ANTI-SPOOFING: Security check failed:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -423,6 +423,43 @@ class AuthService {
|
|||||||
localStorage.removeItem(key)
|
localStorage.removeItem(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async forceClockOut(note) {
|
||||||
|
try {
|
||||||
|
const userId = await this.getUserId()
|
||||||
|
if (!userId) {
|
||||||
|
console.warn('No user ID found, cannot force clock out.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current location to associate with the clock-out event
|
||||||
|
let latitude, longitude
|
||||||
|
latitude = 0
|
||||||
|
longitude = 0
|
||||||
|
|
||||||
|
await apiFetch('/api/clock', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
userId,
|
||||||
|
eventType: 'clock_out',
|
||||||
|
notes: note,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
qrCodeValue: 'FORCE_CLOCK_OUT' // Use a special value for QR code
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Perform a full logout on the client-side
|
||||||
|
await this.logout()
|
||||||
|
|
||||||
|
// Dispatch a global event to notify the UI
|
||||||
|
window.dispatchEvent(new CustomEvent('user-forced-clock-out', { detail: { note } }))
|
||||||
|
|
||||||
|
console.log(`User ${userId} has been forcibly clocked out with note: ${note}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to force clock out:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create and export a singleton instance
|
// Create and export a singleton instance
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { ArrowRightOnRectangleIcon } from '@heroicons/vue/24/outline'
|
import { ArrowRightOnRectangleIcon } from '@heroicons/vue/24/outline'
|
||||||
import { authService } from '@/services/authService.js'
|
import { authService } from '@/services/authService.js'
|
||||||
import { nativeServicesManager } from '@/services/nativeServicesManager.js'
|
import { nativeServicesManager } from '@/services/nativeServicesManager.js'
|
||||||
|
import { antiSpoofingService } from '@/services/antiSpoofingService.js'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -103,6 +104,8 @@ const handleLogin = async () => {
|
|||||||
try {
|
try {
|
||||||
await nativeServicesManager.onUserLogin()
|
await nativeServicesManager.onUserLogin()
|
||||||
console.log('✅ NATIVE SERVICES STARTED')
|
console.log('✅ NATIVE SERVICES STARTED')
|
||||||
|
// Start anti-spoofing checks after native services are ready
|
||||||
|
antiSpoofingService.startSecurityChecks();
|
||||||
} catch (serviceError) {
|
} catch (serviceError) {
|
||||||
console.error('❌ NATIVE SERVICES FAILED:', serviceError)
|
console.error('❌ NATIVE SERVICES FAILED:', serviceError)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,8 @@ const sendClockEvent = async (qrCodeValue, latitude, longitude) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
window.addEventListener('user-forced-clock-out', handleForcedClockOut)
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
userId = await authService.getUserId()
|
userId = await authService.getUserId()
|
||||||
}
|
}
|
||||||
@@ -207,6 +209,8 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('user-forced-clock-out', handleForcedClockOut)
|
||||||
|
|
||||||
if (html5QrCode && html5QrCode.isScanning) {
|
if (html5QrCode && html5QrCode.isScanning) {
|
||||||
stopScanner()
|
stopScanner()
|
||||||
}
|
}
|
||||||
@@ -219,6 +223,10 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const handleForcedClockOut = () => {
|
||||||
|
isClockedIn.value = false
|
||||||
|
}
|
||||||
|
|
||||||
const clearMessages = () => {
|
const clearMessages = () => {
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
successMessage.value = ''
|
successMessage.value = ''
|
||||||
@@ -292,4 +300,3 @@ const onScanFailure = () => {
|
|||||||
// errorMessage.value = t('qrNotDetectedTryAgain')
|
// errorMessage.value = t('qrNotDetectedTryAgain')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user