@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "harheimertc-website",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Loader2
|
||||
@@ -97,6 +97,36 @@
|
||||
<span>{{ isLoading ? 'Anmeldung läuft...' : 'Anmelden' }}</span>
|
||||
</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 class="text-xs text-gray-500 text-center">
|
||||
Falls kein lokaler Passkey verfügbar ist: Smartphone oder USB-Sicherheitsschlüssel verwenden.
|
||||
</p>
|
||||
|
||||
<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 -->
|
||||
<div class="text-center">
|
||||
<NuxtLink
|
||||
@@ -124,7 +154,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { AlertCircle, Check, Loader2, Lock } from 'lucide-vue-next'
|
||||
|
||||
@@ -137,8 +167,26 @@ const formData = ref({
|
||||
})
|
||||
|
||||
const isLoading = ref(false)
|
||||
const passkeyLoading = ref(false)
|
||||
const errorMessage = 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 () => {
|
||||
isLoading.value = true
|
||||
@@ -170,8 +218,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({
|
||||
layout: 'default'
|
||||
|
||||
@@ -198,14 +198,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Passkeys - vorläufig deaktiviert -->
|
||||
<!--
|
||||
<div class="border-t border-gray-200 pt-6 mt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">
|
||||
Passkeys
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Passkeys erlauben eine Anmeldung ohne Passwort (z.B. per Fingerabdruck/FaceID/Windows Hello).
|
||||
Passkeys erlauben eine Anmeldung ohne Passwort. Je nach Gerät erfolgt die Einrichtung lokal oder über Smartphone/USB-Sicherheitsschlüssel.
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mb-4">
|
||||
Hinweis: Unter Firefox wird der Smartphone-Authenticator (QR/Hybrid) bevorzugt.
|
||||
</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
|
||||
@@ -264,7 +272,6 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<!-- Error/Success Messages -->
|
||||
<div
|
||||
@@ -334,7 +341,7 @@ const passkeyLoading = ref(false)
|
||||
const passkeyError = ref('')
|
||||
const isPasskeySupported = ref(false)
|
||||
if (process.client) {
|
||||
isPasskeySupported.value = !!window.PublicKeyCredential
|
||||
isPasskeySupported.value = !!window.PublicKeyCredential && !!window.isSecureContext
|
||||
}
|
||||
|
||||
const formData = ref({
|
||||
@@ -389,15 +396,45 @@ const loadPasskeys = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const isFirefoxBrowser = () => {
|
||||
if (!process.client) return false
|
||||
const ua = navigator.userAgent || ''
|
||||
return /firefox/i.test(ua)
|
||||
}
|
||||
|
||||
const hasPlatformAuthenticator = async () => {
|
||||
if (!process.client || !window.PublicKeyCredential) return false
|
||||
if (typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable !== 'function') return false
|
||||
try {
|
||||
return await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const addPasskey = async () => {
|
||||
passkeyError.value = ''
|
||||
passkeyLoading.value = true
|
||||
try {
|
||||
const name = window.prompt('Name für den Passkey (z.B. "iPhone", "Laptop"):', 'Passkey') || 'Passkey'
|
||||
const res = await $fetch('/api/auth/passkeys/registration-options', { method: 'POST' })
|
||||
const mod = await import('@simplewebauthn/browser')
|
||||
|
||||
// Firefox auf Linux: Smartphone-Flow (Hybrid/QR) bevorzugen.
|
||||
// Chromium: localDevice nur setzen, wenn das Gerät wirklich einen Platform-Authenticator hat.
|
||||
const firefox = isFirefoxBrowser()
|
||||
const platformAvailable = await hasPlatformAuthenticator()
|
||||
const preferredAuthenticatorType = firefox
|
||||
? 'remoteDevice'
|
||||
: (platformAvailable ? 'localDevice' : undefined)
|
||||
const registrationOptionsRequest = preferredAuthenticatorType
|
||||
? { method: 'POST', body: { preferredAuthenticatorType } }
|
||||
: { method: 'POST' }
|
||||
|
||||
const res = await $fetch('/api/auth/passkeys/registration-options', registrationOptionsRequest)
|
||||
|
||||
// @simplewebauthn/browser v13+ erwartet { optionsJSON: options }
|
||||
const credential = await mod.startRegistration({ optionsJSON: res.options })
|
||||
|
||||
await $fetch('/api/auth/passkeys/register', {
|
||||
method: 'POST',
|
||||
body: { credential, name }
|
||||
@@ -405,7 +442,12 @@ const addPasskey = async () => {
|
||||
await loadPasskeys()
|
||||
successMessage.value = 'Passkey hinzugefügt.'
|
||||
} catch (e) {
|
||||
passkeyError.value = e?.data?.message || e?.message || 'Passkey konnte nicht hinzugefügt werden.'
|
||||
const rawMessage = e?.data?.message || e?.message || ''
|
||||
if (String(rawMessage).includes('NotAllowedError')) {
|
||||
passkeyError.value = 'Passkey-Erstellung abgebrochen oder nicht erlaubt. Unter Firefox/Linux ggf. Smartphone-Passkey oder Sicherheitsschlüssel verwenden.'
|
||||
} else {
|
||||
passkeyError.value = rawMessage || 'Passkey konnte nicht hinzugefügt werden.'
|
||||
}
|
||||
} finally {
|
||||
passkeyLoading.value = false
|
||||
}
|
||||
@@ -503,8 +545,9 @@ const handleSave = async () => {
|
||||
|
||||
onMounted(() => {
|
||||
loadProfile()
|
||||
// Passkey-Verwaltung vorläufig deaktiviert
|
||||
// loadPasskeys()
|
||||
if (isPasskeySupported.value) {
|
||||
loadPasskeys()
|
||||
}
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { generateAuthenticationOptions } from '@simplewebauthn/server'
|
||||
import { readUsers } from '../../../utils/auth.js'
|
||||
import { getWebAuthnConfig } from '../../../utils/webauthn-config.js'
|
||||
import { setAuthChallenge } from '../../../utils/webauthn-challenges.js'
|
||||
|
||||
@@ -16,11 +17,31 @@ export default defineEventHandler(async (event) => {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const email = String(body?.email || '').trim().toLowerCase()
|
||||
|
||||
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({
|
||||
rpID: rpId,
|
||||
allowCredentials,
|
||||
userVerification: 'preferred',
|
||||
// Timeout erhöhen für Cross-Device (Standard: 60s, hier: 5 Minuten)
|
||||
timeout: 300000
|
||||
|
||||
@@ -18,6 +18,11 @@ export default defineEventHandler(async (event) => {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const preferredAuthenticatorType = ['securityKey', 'localDevice', 'remoteDevice'].includes(body?.preferredAuthenticatorType)
|
||||
? body.preferredAuthenticatorType
|
||||
: undefined
|
||||
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const user = token ? await getUserFromToken(token) : null
|
||||
|
||||
@@ -64,6 +69,7 @@ export default defineEventHandler(async (event) => {
|
||||
// authenticatorAttachment weglassen = beide Typen erlauben (platform + cross-platform)
|
||||
},
|
||||
excludeCredentials,
|
||||
preferredAuthenticatorType,
|
||||
// Timeout erhöhen für Cross-Device (Standard: 60s, hier: 5 Minuten)
|
||||
timeout: 300000
|
||||
})
|
||||
|
||||
@@ -54,15 +54,21 @@ export const useAuthStore = defineStore('auth', {
|
||||
return response
|
||||
},
|
||||
|
||||
async passkeyLogin() {
|
||||
async passkeyLogin(email = '') {
|
||||
// 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.')
|
||||
}
|
||||
|
||||
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 credential = await mod.startAuthentication(options)
|
||||
const credential = await mod.startAuthentication({ optionsJSON: options })
|
||||
|
||||
const response = await $fetch('/api/auth/passkeys/login', {
|
||||
method: 'POST',
|
||||
@@ -83,7 +89,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
this.user = null
|
||||
this.roles = []
|
||||
this.role = null
|
||||
} catch (_error) {
|
||||
} catch (error) {
|
||||
console.error('Logout fehlgeschlagen:', error)
|
||||
throw error
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user