Implement passkey recovery feature, including email link requests and registration options. Update login and registration pages to support passkey authentication, with UI enhancements for user experience. Add server-side handling for passkey registration and login, including account activation checks. Update environment configuration for passkey recovery TTL settings.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 48s
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 48s
This commit is contained in:
120
server/api/auth/passkeys/recovery/complete.post.js
Normal file
120
server/api/auth/passkeys/recovery/complete.post.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import { verifyRegistrationResponse } from '@simplewebauthn/server'
|
||||
import { readUsers, writeUsers } from '../../../../utils/auth.js'
|
||||
import { consumePreRegistration } from '../../../../utils/webauthn-challenges.js'
|
||||
import { getWebAuthnConfig } from '../../../../utils/webauthn-config.js'
|
||||
import { toBase64Url } from '../../../../utils/webauthn-encoding.js'
|
||||
import { assertRateLimit, getClientIp } from '../../../../utils/rate-limit.js'
|
||||
import { writeAuditLog } from '../../../../utils/audit-log.js'
|
||||
|
||||
function findUserAndToken(users, userId, tokenHash) {
|
||||
const now = Date.now()
|
||||
const idx = users.findIndex(u => String(u.id) === String(userId))
|
||||
if (idx === -1) return { idx: -1, tokenEntry: null }
|
||||
const user = users[idx]
|
||||
const list = Array.isArray(user.passkeyRecoveryTokens) ? user.passkeyRecoveryTokens : []
|
||||
const tokenEntry = list.find(t =>
|
||||
t &&
|
||||
t.tokenHash === tokenHash &&
|
||||
!t.usedAt &&
|
||||
t.expiresAt &&
|
||||
new Date(t.expiresAt).getTime() > now
|
||||
)
|
||||
return { idx, tokenEntry }
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const ip = getClientIp(event)
|
||||
const body = await readBody(event)
|
||||
const recoveryId = String(body?.recoveryId || '')
|
||||
const response = body?.credential
|
||||
const passkeyName = body?.name ? String(body.name).slice(0, 80) : 'Passkey'
|
||||
|
||||
if (!recoveryId || !response) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Ungültige Anfrage' })
|
||||
}
|
||||
|
||||
assertRateLimit(event, {
|
||||
name: 'auth:passkey-recovery:complete:ip',
|
||||
keyParts: [ip],
|
||||
windowMs: 10 * 60 * 1000,
|
||||
maxAttempts: 30,
|
||||
lockoutMs: 10 * 60 * 1000
|
||||
})
|
||||
|
||||
const pre = consumePreRegistration(recoveryId)
|
||||
if (!pre) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Recovery-Session abgelaufen. Bitte erneut versuchen.' })
|
||||
}
|
||||
|
||||
const { challenge, tokenHash, userId } = pre
|
||||
assertRateLimit(event, {
|
||||
name: 'auth:passkey-recovery:complete:token',
|
||||
keyParts: [tokenHash],
|
||||
windowMs: 10 * 60 * 1000,
|
||||
maxAttempts: 10,
|
||||
lockoutMs: 30 * 60 * 1000
|
||||
})
|
||||
|
||||
const users = await readUsers()
|
||||
const { idx, tokenEntry } = findUserAndToken(users, userId, tokenHash)
|
||||
if (idx === -1 || !tokenEntry) {
|
||||
await writeAuditLog('auth.passkey.recovery.complete.failed', { ip, reason: 'invalid_token', userId })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Ungültiger oder abgelaufener Token' })
|
||||
}
|
||||
|
||||
const { origin, rpId, requireUV } = getWebAuthnConfig()
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpId,
|
||||
requireUserVerification: requireUV
|
||||
})
|
||||
|
||||
const { verified, registrationInfo } = verification
|
||||
if (!verified || !registrationInfo) {
|
||||
await writeAuditLog('auth.passkey.recovery.complete.failed', { ip, reason: 'verification_failed', userId })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Passkey-Registrierung fehlgeschlagen' })
|
||||
}
|
||||
|
||||
const {
|
||||
credentialID,
|
||||
credentialPublicKey,
|
||||
counter,
|
||||
credentialDeviceType,
|
||||
credentialBackedUp
|
||||
} = registrationInfo
|
||||
|
||||
const credentialId = toBase64Url(credentialID)
|
||||
const publicKey = toBase64Url(credentialPublicKey)
|
||||
|
||||
const user = users[idx]
|
||||
if (!Array.isArray(user.passkeys)) user.passkeys = []
|
||||
|
||||
// Duplikate verhindern
|
||||
if (!user.passkeys.some(pk => pk.credentialId === credentialId)) {
|
||||
user.passkeys.push({
|
||||
id: `${Date.now()}`,
|
||||
credentialId,
|
||||
publicKey,
|
||||
counter: Number(counter) || 0,
|
||||
transports: Array.isArray(response.transports) ? response.transports : undefined,
|
||||
deviceType: credentialDeviceType,
|
||||
backedUp: !!credentialBackedUp,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: null,
|
||||
name: passkeyName
|
||||
})
|
||||
}
|
||||
|
||||
// Token als benutzt markieren (one-time)
|
||||
tokenEntry.usedAt = new Date().toISOString()
|
||||
|
||||
users[idx] = user
|
||||
await writeUsers(users)
|
||||
|
||||
await writeAuditLog('auth.passkey.recovery.complete.success', { ip, userId: user.id })
|
||||
return { success: true, message: 'Passkey hinzugefügt. Sie können sich jetzt mit dem neuen Passkey anmelden.' }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user