feat: App lock with biometric/PIN

- LockScreen component with PIN pad
- SecuritySettings for setup
- Biometric (WebAuthn) support
- PIN fallback (6 digits)
- Auto-lock after 30s in background
- Lock on app start if enabled
- Settings page integration
This commit is contained in:
2026-03-12 20:54:53 +00:00
parent 21c88be74f
commit e5d09e9c80
26 changed files with 720 additions and 53 deletions

View File

@@ -1,16 +1,87 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import LockScreen from '@/components/LockScreen.vue'
const authStore = useAuthStore()
const isLocked = ref(false)
const lastActiveTime = ref(Date.now())
const LOCK_TIMEOUT = 30000 // 30 seconds of inactivity
// Check if lock is enabled
const lockEnabled = computed(() => {
return localStorage.getItem('lockMethod') &&
localStorage.getItem('lockMethod') !== 'none' &&
authStore.isAuthenticated
})
onMounted(async () => {
if (authStore.hasStoredToken) {
await authStore.fetchCurrentUser()
}
// Check if we should show lock screen on app start
if (lockEnabled.value) {
const lastUnlock = localStorage.getItem('lastUnlockTime')
const now = Date.now()
// Lock if no previous unlock or more than 30 seconds ago
if (!lastUnlock || (now - parseInt(lastUnlock)) > LOCK_TIMEOUT) {
isLocked.value = true
}
}
// Listen for visibility changes (app goes to background/foreground)
document.addEventListener('visibilitychange', handleVisibilityChange)
// Track user activity
document.addEventListener('touchstart', updateLastActive)
document.addEventListener('click', updateLastActive)
document.addEventListener('keydown', updateLastActive)
})
onUnmounted(() => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
document.removeEventListener('touchstart', updateLastActive)
document.removeEventListener('click', updateLastActive)
document.removeEventListener('keydown', updateLastActive)
})
function updateLastActive() {
lastActiveTime.value = Date.now()
}
function handleVisibilityChange() {
if (document.visibilityState === 'hidden') {
// App went to background - record time
localStorage.setItem('backgroundTime', String(Date.now()))
} else if (document.visibilityState === 'visible') {
// App came back to foreground
if (lockEnabled.value) {
const backgroundTime = localStorage.getItem('backgroundTime')
const now = Date.now()
// Lock if was in background for more than 30 seconds
if (backgroundTime && (now - parseInt(backgroundTime)) > LOCK_TIMEOUT) {
isLocked.value = true
}
}
}
}
function handleUnlock() {
isLocked.value = false
localStorage.setItem('lastUnlockTime', String(Date.now()))
}
</script>
<template>
<router-view />
<!-- Lock Screen Overlay -->
<LockScreen
v-if="isLocked && lockEnabled"
@unlocked="handleUnlock"
/>
<!-- Main App -->
<router-view v-show="!isLocked || !lockEnabled" />
</template>

View File

@@ -0,0 +1,201 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
const emit = defineEmits<{
unlocked: []
}>()
const authStore = useAuthStore()
const lockMethod = ref<'biometric' | 'pin' | null>(null)
const pin = ref('')
const error = ref('')
const loading = ref(false)
const pinDigits = ref<string[]>(['', '', '', '', '', ''])
const currentDigit = ref(0)
onMounted(() => {
// Get user's preferred lock method
lockMethod.value = localStorage.getItem('lockMethod') as 'biometric' | 'pin' | null
// If biometric is set, try it automatically
if (lockMethod.value === 'biometric') {
tryBiometric()
}
})
async function tryBiometric() {
loading.value = true
error.value = ''
try {
// Check if WebAuthn is available
if (!window.PublicKeyCredential) {
throw new Error('Biometrie nicht verfügbar')
}
// Get stored credential ID
const credentialId = localStorage.getItem('biometricCredentialId')
if (!credentialId) {
throw new Error('Keine biometrischen Daten gespeichert')
}
// Request biometric authentication
const credential = await navigator.credentials.get({
publicKey: {
challenge: new Uint8Array(32),
timeout: 60000,
userVerification: 'required',
allowCredentials: [{
id: Uint8Array.from(atob(credentialId), c => c.charCodeAt(0)),
type: 'public-key',
transports: ['internal']
}]
}
})
if (credential) {
emit('unlocked')
}
} catch (e: any) {
console.error('Biometric auth failed:', e)
error.value = 'Biometrie fehlgeschlagen. Bitte PIN verwenden.'
lockMethod.value = 'pin'
}
loading.value = false
}
function addDigit(digit: string) {
if (currentDigit.value < 6) {
pinDigits.value[currentDigit.value] = digit
currentDigit.value++
if (currentDigit.value === 6) {
verifyPin()
}
}
}
function removeDigit() {
if (currentDigit.value > 0) {
currentDigit.value--
pinDigits.value[currentDigit.value] = ''
}
error.value = ''
}
function clearPin() {
pinDigits.value = ['', '', '', '', '', '']
currentDigit.value = 0
error.value = ''
}
async function verifyPin() {
const enteredPin = pinDigits.value.join('')
const storedPin = localStorage.getItem('appPin')
if (enteredPin === storedPin) {
emit('unlocked')
} else {
error.value = 'Falscher PIN'
// Shake animation + clear after delay
setTimeout(clearPin, 500)
}
}
function switchToPin() {
lockMethod.value = 'pin'
error.value = ''
}
</script>
<template>
<div class="fixed inset-0 bg-gradient-to-br from-purple-600 to-indigo-700 flex flex-col items-center justify-center z-50">
<!-- Logo -->
<div class="text-6xl mb-4">🔐</div>
<h1 class="text-2xl font-bold text-white mb-2">SeCu</h1>
<p class="text-white/70 mb-8">Bitte entsperren</p>
<!-- Biometric -->
<div v-if="lockMethod === 'biometric' && !error" class="text-center">
<button
@click="tryBiometric"
:disabled="loading"
class="w-20 h-20 bg-white/20 rounded-full flex items-center justify-center text-4xl mb-4 hover:bg-white/30 transition"
>
<span v-if="loading" class="animate-spin"></span>
<span v-else>👆</span>
</button>
<p class="text-white/70 text-sm">Fingerabdruck oder Face ID</p>
<button
@click="switchToPin"
class="mt-4 text-white/50 text-sm underline"
>
Stattdessen PIN verwenden
</button>
</div>
<!-- PIN Entry -->
<div v-else class="text-center">
<!-- PIN Dots -->
<div class="flex space-x-3 mb-8">
<div
v-for="(digit, i) in pinDigits"
:key="i"
:class="[
'w-4 h-4 rounded-full transition-all',
digit ? 'bg-white scale-110' : 'bg-white/30',
error ? 'animate-shake bg-red-400' : ''
]"
></div>
</div>
<!-- Error -->
<p v-if="error" class="text-red-300 text-sm mb-4">{{ error }}</p>
<!-- Number Pad -->
<div class="grid grid-cols-3 gap-4 max-w-xs">
<button
v-for="n in [1,2,3,4,5,6,7,8,9]"
:key="n"
@click="addDigit(String(n))"
class="w-16 h-16 bg-white/20 rounded-full text-white text-2xl font-semibold hover:bg-white/30 transition"
>
{{ n }}
</button>
<button
v-if="lockMethod === 'biometric' || localStorage.getItem('biometricCredentialId')"
@click="tryBiometric"
class="w-16 h-16 bg-white/20 rounded-full text-white text-2xl hover:bg-white/30 transition"
>
👆
</button>
<div v-else class="w-16 h-16"></div>
<button
@click="addDigit('0')"
class="w-16 h-16 bg-white/20 rounded-full text-white text-2xl font-semibold hover:bg-white/30 transition"
>
0
</button>
<button
@click="removeDigit"
class="w-16 h-16 bg-white/10 rounded-full text-white text-xl hover:bg-white/20 transition"
>
</button>
</div>
</div>
</div>
</template>
<style scoped>
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
.animate-shake {
animation: shake 0.3s ease-in-out;
}
</style>

View File

@@ -0,0 +1,333 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
const emit = defineEmits<{
close: []
}>()
const step = ref<'menu' | 'setup-pin' | 'confirm-pin' | 'setup-biometric'>('menu')
const currentPin = ref('')
const confirmPin = ref('')
const error = ref('')
const pinDigits = ref<string[]>(['', '', '', '', '', ''])
const currentDigit = ref(0)
const hasBiometric = ref(false)
const hasPin = ref(false)
const hasBiometricSupport = ref(false)
const lockMethod = ref<'biometric' | 'pin' | 'none'>('none')
onMounted(async () => {
// Check what's configured
hasPin.value = !!localStorage.getItem('appPin')
hasBiometric.value = !!localStorage.getItem('biometricCredentialId')
lockMethod.value = localStorage.getItem('lockMethod') as any || 'none'
// Check if device supports biometric
if (window.PublicKeyCredential) {
try {
hasBiometricSupport.value = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
} catch {
hasBiometricSupport.value = false
}
}
})
function addDigit(digit: string) {
if (currentDigit.value < 6) {
pinDigits.value[currentDigit.value] = digit
currentDigit.value++
if (currentDigit.value === 6) {
if (step.value === 'setup-pin') {
currentPin.value = pinDigits.value.join('')
clearPinInput()
step.value = 'confirm-pin'
} else if (step.value === 'confirm-pin') {
confirmPin.value = pinDigits.value.join('')
verifyPinMatch()
}
}
}
}
function removeDigit() {
if (currentDigit.value > 0) {
currentDigit.value--
pinDigits.value[currentDigit.value] = ''
}
error.value = ''
}
function clearPinInput() {
pinDigits.value = ['', '', '', '', '', '']
currentDigit.value = 0
error.value = ''
}
function verifyPinMatch() {
if (currentPin.value === confirmPin.value) {
localStorage.setItem('appPin', currentPin.value)
localStorage.setItem('lockMethod', 'pin')
hasPin.value = true
lockMethod.value = 'pin'
step.value = 'menu'
currentPin.value = ''
confirmPin.value = ''
clearPinInput()
} else {
error.value = 'PINs stimmen nicht überein'
setTimeout(() => {
clearPinInput()
step.value = 'setup-pin'
currentPin.value = ''
confirmPin.value = ''
}, 1000)
}
}
async function setupBiometric() {
step.value = 'setup-biometric'
error.value = ''
try {
if (!window.PublicKeyCredential) {
throw new Error('WebAuthn nicht unterstützt')
}
// Generate a random user ID
const userId = new Uint8Array(16)
crypto.getRandomValues(userId)
// Create credential
const credential = await navigator.credentials.create({
publicKey: {
challenge: new Uint8Array(32),
rp: {
name: 'SeCu',
id: window.location.hostname
},
user: {
id: userId,
name: localStorage.getItem('userEmail') || 'user',
displayName: 'SeCu User'
},
pubKeyCredParams: [
{ type: 'public-key', alg: -7 }, // ES256
{ type: 'public-key', alg: -257 } // RS256
],
authenticatorSelection: {
authenticatorAttachment: 'platform',
userVerification: 'required',
residentKey: 'preferred'
},
timeout: 60000,
attestation: 'none'
}
}) as PublicKeyCredential
if (credential) {
// Store credential ID
const credentialId = btoa(String.fromCharCode(...new Uint8Array(credential.rawId)))
localStorage.setItem('biometricCredentialId', credentialId)
localStorage.setItem('lockMethod', 'biometric')
hasBiometric.value = true
lockMethod.value = 'biometric'
step.value = 'menu'
}
} catch (e: any) {
console.error('Biometric setup failed:', e)
error.value = e.message || 'Biometrie-Einrichtung fehlgeschlagen'
step.value = 'menu'
}
}
function disableLock() {
if (confirm('App-Sperre wirklich deaktivieren?')) {
localStorage.removeItem('appPin')
localStorage.removeItem('biometricCredentialId')
localStorage.removeItem('lockMethod')
hasPin.value = false
hasBiometric.value = false
lockMethod.value = 'none'
}
}
function setLockMethod(method: 'biometric' | 'pin') {
localStorage.setItem('lockMethod', method)
lockMethod.value = method
}
</script>
<template>
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div class="bg-white rounded-2xl shadow-xl w-full max-w-md overflow-hidden">
<!-- Header -->
<div class="bg-gradient-to-r from-purple-600 to-indigo-600 p-6 text-white">
<div class="flex justify-between items-center">
<h2 class="text-xl font-bold">🔐 App-Sicherheit</h2>
<button @click="emit('close')" class="text-white/70 hover:text-white"></button>
</div>
</div>
<!-- Content -->
<div class="p-6">
<!-- Menu -->
<div v-if="step === 'menu'" class="space-y-4">
<p class="text-gray-600 text-sm mb-4">
Schütze deine App mit Fingerabdruck, Face ID oder PIN.
</p>
<!-- Current Status -->
<div class="bg-gray-50 rounded-lg p-4 mb-4">
<div class="flex items-center justify-between">
<span class="text-gray-700">Aktueller Schutz:</span>
<span :class="[
'px-3 py-1 rounded-full text-sm font-medium',
lockMethod === 'none' ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'
]">
{{ lockMethod === 'biometric' ? '👆 Biometrie' : lockMethod === 'pin' ? '🔢 PIN' : '❌ Aus' }}
</span>
</div>
</div>
<!-- Options -->
<div class="space-y-3">
<!-- Biometric Option -->
<button
v-if="hasBiometricSupport"
@click="hasBiometric ? setLockMethod('biometric') : setupBiometric()"
:class="[
'w-full p-4 rounded-lg border-2 text-left transition flex items-center',
lockMethod === 'biometric'
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-purple-300'
]"
>
<span class="text-2xl mr-4">👆</span>
<div class="flex-1">
<div class="font-semibold">Fingerabdruck / Face ID</div>
<div class="text-sm text-gray-500">
{{ hasBiometric ? 'Eingerichtet' : 'Jetzt einrichten' }}
</div>
</div>
<span v-if="lockMethod === 'biometric'" class="text-purple-600"></span>
</button>
<!-- PIN Option -->
<button
@click="hasPin ? setLockMethod('pin') : (step = 'setup-pin')"
:class="[
'w-full p-4 rounded-lg border-2 text-left transition flex items-center',
lockMethod === 'pin'
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-purple-300'
]"
>
<span class="text-2xl mr-4">🔢</span>
<div class="flex-1">
<div class="font-semibold">PIN-Code (6 Ziffern)</div>
<div class="text-sm text-gray-500">
{{ hasPin ? 'Eingerichtet' : 'Jetzt einrichten' }}
</div>
</div>
<span v-if="lockMethod === 'pin'" class="text-purple-600"></span>
</button>
<!-- Disable -->
<button
v-if="lockMethod !== 'none'"
@click="disableLock"
class="w-full p-4 rounded-lg border-2 border-red-200 text-left transition flex items-center hover:border-red-300 hover:bg-red-50"
>
<span class="text-2xl mr-4">🔓</span>
<div class="flex-1">
<div class="font-semibold text-red-700">Sperre deaktivieren</div>
<div class="text-sm text-gray-500">Nicht empfohlen</div>
</div>
</button>
</div>
<!-- Change PIN -->
<button
v-if="hasPin"
@click="step = 'setup-pin'"
class="w-full text-center text-purple-600 text-sm hover:underline mt-4"
>
PIN ändern
</button>
</div>
<!-- Setup PIN -->
<div v-else-if="step === 'setup-pin' || step === 'confirm-pin'" class="text-center">
<h3 class="text-lg font-semibold mb-2">
{{ step === 'setup-pin' ? 'Neuen PIN eingeben' : 'PIN bestätigen' }}
</h3>
<p class="text-gray-500 text-sm mb-6">6 Ziffern</p>
<!-- PIN Dots -->
<div class="flex justify-center space-x-3 mb-8">
<div
v-for="(digit, i) in pinDigits"
:key="i"
:class="[
'w-4 h-4 rounded-full transition-all',
digit ? 'bg-purple-600 scale-110' : 'bg-gray-300',
error ? 'bg-red-400 animate-shake' : ''
]"
></div>
</div>
<!-- Error -->
<p v-if="error" class="text-red-500 text-sm mb-4">{{ error }}</p>
<!-- Number Pad -->
<div class="grid grid-cols-3 gap-3 max-w-xs mx-auto">
<button
v-for="n in [1,2,3,4,5,6,7,8,9]"
:key="n"
@click="addDigit(String(n))"
class="w-14 h-14 bg-gray-100 rounded-full text-xl font-semibold hover:bg-gray-200 transition"
>
{{ n }}
</button>
<button
@click="step = 'menu'; clearPinInput()"
class="w-14 h-14 text-gray-500 text-sm"
>
Zurück
</button>
<button
@click="addDigit('0')"
class="w-14 h-14 bg-gray-100 rounded-full text-xl font-semibold hover:bg-gray-200 transition"
>
0
</button>
<button
@click="removeDigit"
class="w-14 h-14 text-xl hover:bg-gray-100 rounded-full transition"
>
</button>
</div>
</div>
<!-- Setup Biometric -->
<div v-else-if="step === 'setup-biometric'" class="text-center py-8">
<div class="text-6xl mb-4 animate-pulse">👆</div>
<h3 class="text-lg font-semibold mb-2">Biometrie einrichten</h3>
<p class="text-gray-500 text-sm mb-4">
Bitte Fingerabdruck scannen oder Face ID verwenden...
</p>
<p v-if="error" class="text-red-500 text-sm">{{ error }}</p>
<button
@click="step = 'menu'"
class="mt-4 text-gray-500 text-sm hover:underline"
>
Abbrechen
</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { api } from '@/api'
import SecuritySettings from '@/components/SecuritySettings.vue'
const authStore = useAuthStore()
@@ -11,6 +12,32 @@ const confirmPassword = ref('')
const loading = ref(false)
const message = ref('')
const error = ref('')
const showSecuritySettings = ref(false)
// App lock status
const lockMethod = ref<string | null>(null)
onMounted(() => {
lockMethod.value = localStorage.getItem('lockMethod')
// Store email for biometric setup
if (authStore.user?.email) {
localStorage.setItem('userEmail', authStore.user.email)
}
})
const lockStatusText = computed(() => {
if (!lockMethod.value || lockMethod.value === 'none') return 'Deaktiviert'
if (lockMethod.value === 'biometric') return 'Fingerabdruck / Face ID'
if (lockMethod.value === 'pin') return 'PIN-Code'
return 'Unbekannt'
})
const lockStatusClass = computed(() => {
if (!lockMethod.value || lockMethod.value === 'none') {
return 'bg-red-100 text-red-700'
}
return 'bg-green-100 text-green-700'
})
async function changePassword() {
if (newPassword.value !== confirmPassword.value) {
@@ -42,6 +69,11 @@ async function changePassword() {
loading.value = false
}
}
function handleSecurityClose() {
showSecuritySettings.value = false
lockMethod.value = localStorage.getItem('lockMethod')
}
</script>
<template>
@@ -50,7 +82,7 @@ async function changePassword() {
<!-- Profile -->
<div class="card">
<h2 class="text-lg font-semibold mb-4">Profil</h2>
<h2 class="text-lg font-semibold mb-4">👤 Profil</h2>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm text-gray-500">Name</label>
@@ -67,9 +99,33 @@ async function changePassword() {
</div>
</div>
<!-- App Security -->
<div class="card">
<h2 class="text-lg font-semibold mb-4">🔐 App-Sicherheit</h2>
<p class="text-gray-600 text-sm mb-4">
Schütze deine App mit Fingerabdruck, Face ID oder PIN.
Bei jedem Öffnen der App musst du dich verifizieren.
</p>
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<div class="font-medium">App-Sperre</div>
<div :class="['inline-block px-2 py-1 rounded text-sm mt-1', lockStatusClass]">
{{ lockStatusText }}
</div>
</div>
<button
@click="showSecuritySettings = true"
class="btn btn-primary"
>
Konfigurieren
</button>
</div>
</div>
<!-- Change Password -->
<div class="card">
<h2 class="text-lg font-semibold mb-4">Passwort ändern</h2>
<h2 class="text-lg font-semibold mb-4">🔑 Passwort ändern</h2>
<form @submit.prevent="changePassword" class="space-y-4 max-w-md">
<div>
<label class="block text-sm font-medium mb-1">Aktuelles Passwort</label>
@@ -92,5 +148,11 @@ async function changePassword() {
</button>
</form>
</div>
<!-- Security Settings Modal -->
<SecuritySettings
v-if="showSecuritySettings"
@close="handleSecurityClose"
/>
</div>
</template>