Implement passkey login functionality and enhance passkey support checks
This commit is contained in:
@@ -86,7 +86,7 @@
|
|||||||
<!-- Submit Button -->
|
<!-- Submit Button -->
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading || passkeyLoading"
|
||||||
class="w-full px-6 py-3 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-400 text-white font-semibold rounded-lg transition-colors flex items-center justify-center"
|
class="w-full px-6 py-3 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-400 text-white font-semibold rounded-lg transition-colors flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<Loader2
|
<Loader2
|
||||||
@@ -97,6 +97,32 @@
|
|||||||
<span>{{ isLoading ? 'Anmeldung läuft...' : 'Anmelden' }}</span>
|
<span>{{ isLoading ? 'Anmeldung läuft...' : 'Anmelden' }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="isLoading || passkeyLoading || !isPasskeySupported"
|
||||||
|
class="w-full px-6 py-3 border border-gray-300 bg-white hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400 text-gray-800 font-semibold rounded-lg transition-colors flex items-center justify-center"
|
||||||
|
@click="handlePasskeyLogin"
|
||||||
|
>
|
||||||
|
<Loader2
|
||||||
|
v-if="passkeyLoading"
|
||||||
|
:size="20"
|
||||||
|
class="mr-2 animate-spin"
|
||||||
|
/>
|
||||||
|
<Lock
|
||||||
|
v-else
|
||||||
|
:size="18"
|
||||||
|
class="mr-2"
|
||||||
|
/>
|
||||||
|
<span>{{ passkeyLoading ? 'Passkey-Anmeldung läuft...' : 'Mit Passkey anmelden' }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="!isPasskeySupported"
|
||||||
|
class="text-xs text-gray-500 text-center"
|
||||||
|
>
|
||||||
|
{{ passkeySupportReason || 'Passkeys sind in diesem Browser aktuell nicht verfügbar.' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<!-- Forgot Password Link -->
|
<!-- Forgot Password Link -->
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@@ -124,7 +150,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { AlertCircle, Check, Loader2, Lock } from 'lucide-vue-next'
|
import { AlertCircle, Check, Loader2, Lock } from 'lucide-vue-next'
|
||||||
|
|
||||||
@@ -137,8 +163,26 @@ const formData = ref({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
const passkeyLoading = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const successMessage = ref('')
|
const successMessage = ref('')
|
||||||
|
const isPasskeySupported = ref(false)
|
||||||
|
const passkeySupportReason = ref('')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const hasPublicKeyCredential = typeof window !== 'undefined' && typeof window.PublicKeyCredential !== 'undefined'
|
||||||
|
const secureContext = typeof window !== 'undefined' && !!window.isSecureContext
|
||||||
|
|
||||||
|
isPasskeySupported.value = !!(hasPublicKeyCredential && secureContext)
|
||||||
|
|
||||||
|
if (!secureContext) {
|
||||||
|
passkeySupportReason.value = 'Passkeys benötigen HTTPS (oder localhost).'
|
||||||
|
} else if (!hasPublicKeyCredential) {
|
||||||
|
passkeySupportReason.value = 'Dieser Browser unterstützt WebAuthn/Passkeys nicht.'
|
||||||
|
} else {
|
||||||
|
passkeySupportReason.value = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
@@ -170,8 +214,35 @@ const handleLogin = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Passkey-Login vorläufig deaktiviert
|
const handlePasskeyLogin = async () => {
|
||||||
// const handlePasskeyLogin = async () => { ... }
|
passkeyLoading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
successMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authStore.passkeyLogin(formData.value.email)
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
successMessage.value = 'Passkey-Anmeldung erfolgreich! Sie werden weitergeleitet...'
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const roles = response.user.roles || (response.user.role ? [response.user.role] : [])
|
||||||
|
if (roles.includes('trainer')) {
|
||||||
|
router.push('/cms/kontaktanfragen')
|
||||||
|
} else if (roles.includes('admin') || roles.includes('vorstand') || roles.includes('newsletter')) {
|
||||||
|
router.push('/cms')
|
||||||
|
} else {
|
||||||
|
router.push('/mitgliederbereich')
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error?.data?.message || error?.message || 'Passkey-Anmeldung fehlgeschlagen.'
|
||||||
|
errorMessage.value = message
|
||||||
|
} finally {
|
||||||
|
passkeyLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'default'
|
layout: 'default'
|
||||||
|
|||||||
@@ -198,8 +198,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Passkeys - vorläufig deaktiviert -->
|
|
||||||
<!--
|
|
||||||
<div class="border-t border-gray-200 pt-6 mt-6">
|
<div class="border-t border-gray-200 pt-6 mt-6">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">
|
<h3 class="text-lg font-semibold text-gray-900 mb-2">
|
||||||
Passkeys
|
Passkeys
|
||||||
@@ -208,6 +206,13 @@
|
|||||||
Passkeys erlauben eine Anmeldung ohne Passwort (z.B. per Fingerabdruck/FaceID/Windows Hello).
|
Passkeys erlauben eine Anmeldung ohne Passwort (z.B. per Fingerabdruck/FaceID/Windows Hello).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="!isPasskeySupported"
|
||||||
|
class="text-sm text-amber-700 mb-4"
|
||||||
|
>
|
||||||
|
Passkeys sind in diesem Browser bzw. ohne HTTPS nicht verfügbar.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="passkeyError"
|
v-if="passkeyError"
|
||||||
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm mb-3"
|
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm mb-3"
|
||||||
@@ -264,7 +269,6 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
-->
|
|
||||||
|
|
||||||
<!-- Error/Success Messages -->
|
<!-- Error/Success Messages -->
|
||||||
<div
|
<div
|
||||||
@@ -334,7 +338,7 @@ const passkeyLoading = ref(false)
|
|||||||
const passkeyError = ref('')
|
const passkeyError = ref('')
|
||||||
const isPasskeySupported = ref(false)
|
const isPasskeySupported = ref(false)
|
||||||
if (process.client) {
|
if (process.client) {
|
||||||
isPasskeySupported.value = !!window.PublicKeyCredential
|
isPasskeySupported.value = !!window.PublicKeyCredential && !!window.isSecureContext
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = ref({
|
const formData = ref({
|
||||||
@@ -503,8 +507,9 @@ const handleSave = async () => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadProfile()
|
loadProfile()
|
||||||
// Passkey-Verwaltung vorläufig deaktiviert
|
if (isPasskeySupported.value) {
|
||||||
// loadPasskeys()
|
loadPasskeys()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { generateAuthenticationOptions } from '@simplewebauthn/server'
|
import { generateAuthenticationOptions } from '@simplewebauthn/server'
|
||||||
|
import { readUsers } from '../../../utils/auth.js'
|
||||||
import { getWebAuthnConfig } from '../../../utils/webauthn-config.js'
|
import { getWebAuthnConfig } from '../../../utils/webauthn-config.js'
|
||||||
import { setAuthChallenge } from '../../../utils/webauthn-challenges.js'
|
import { setAuthChallenge } from '../../../utils/webauthn-challenges.js'
|
||||||
|
|
||||||
@@ -16,11 +17,31 @@ export default defineEventHandler(async (event) => {
|
|||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const body = await readBody(event)
|
||||||
|
const email = String(body?.email || '').trim().toLowerCase()
|
||||||
|
|
||||||
const { rpId } = getWebAuthnConfig()
|
const { rpId } = getWebAuthnConfig()
|
||||||
|
|
||||||
// Username-less / discoverable credentials: allowCredentials absichtlich leer
|
let allowCredentials
|
||||||
|
if (email) {
|
||||||
|
const users = await readUsers()
|
||||||
|
const user = users.find(u => String(u.email || '').toLowerCase() === email)
|
||||||
|
const passkeys = Array.isArray(user?.passkeys) ? user.passkeys : []
|
||||||
|
|
||||||
|
allowCredentials = passkeys
|
||||||
|
.filter(pk => pk?.credentialId)
|
||||||
|
.map(pk => ({
|
||||||
|
id: pk.credentialId,
|
||||||
|
type: 'public-key',
|
||||||
|
transports: pk.transports || undefined
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ohne E-Mail: discoverable Credentials (username-less).
|
||||||
|
// Mit E-Mail: allowCredentials nutzen, damit auch nicht-discoverable Credentials funktionieren.
|
||||||
const options = await generateAuthenticationOptions({
|
const options = await generateAuthenticationOptions({
|
||||||
rpID: rpId,
|
rpID: rpId,
|
||||||
|
allowCredentials,
|
||||||
userVerification: 'preferred',
|
userVerification: 'preferred',
|
||||||
// Timeout erhöhen für Cross-Device (Standard: 60s, hier: 5 Minuten)
|
// Timeout erhöhen für Cross-Device (Standard: 60s, hier: 5 Minuten)
|
||||||
timeout: 300000
|
timeout: 300000
|
||||||
|
|||||||
@@ -54,15 +54,21 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
return response
|
return response
|
||||||
},
|
},
|
||||||
|
|
||||||
async passkeyLogin() {
|
async passkeyLogin(email = '') {
|
||||||
// Client-only
|
// Client-only
|
||||||
if (typeof window === 'undefined' || !window.PublicKeyCredential) {
|
if (typeof window === 'undefined' || !window.PublicKeyCredential || !window.isSecureContext) {
|
||||||
throw new Error('Passkeys werden von diesem Browser nicht unterstützt.')
|
throw new Error('Passkeys werden von diesem Browser nicht unterstützt.')
|
||||||
}
|
}
|
||||||
|
|
||||||
const { options } = await $fetch('/api/auth/passkeys/authentication-options', { method: 'POST' })
|
const normalizedEmail = String(email || '').trim().toLowerCase()
|
||||||
|
|
||||||
|
const { options } = await $fetch('/api/auth/passkeys/authentication-options', {
|
||||||
|
method: 'POST',
|
||||||
|
body: normalizedEmail ? { email: normalizedEmail } : {}
|
||||||
|
})
|
||||||
|
|
||||||
const mod = await import('@simplewebauthn/browser')
|
const mod = await import('@simplewebauthn/browser')
|
||||||
const credential = await mod.startAuthentication(options)
|
const credential = await mod.startAuthentication({ optionsJSON: options })
|
||||||
|
|
||||||
const response = await $fetch('/api/auth/passkeys/login', {
|
const response = await $fetch('/api/auth/passkeys/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -83,7 +89,7 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
this.user = null
|
this.user = null
|
||||||
this.roles = []
|
this.roles = []
|
||||||
this.role = null
|
this.role = null
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
console.error('Logout fehlgeschlagen:', error)
|
console.error('Logout fehlgeschlagen:', error)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user