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:
89
server/api/auth/passkeys/recovery/options.get.js
Normal file
89
server/api/auth/passkeys/recovery/options.get.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import crypto from 'crypto'
|
||||
import { generateRegistrationOptions } from '@simplewebauthn/server'
|
||||
import { readUsers } from '../../../../utils/auth.js'
|
||||
import { getWebAuthnConfig } from '../../../../utils/webauthn-config.js'
|
||||
import { hashRecoveryToken } from '../../../../utils/passkey-recovery.js'
|
||||
import { setPreRegistration } from '../../../../utils/webauthn-challenges.js'
|
||||
import { assertRateLimit, getClientIp } from '../../../../utils/rate-limit.js'
|
||||
import { writeAuditLog } from '../../../../utils/audit-log.js'
|
||||
|
||||
function findUserByTokenHash(users, tokenHash) {
|
||||
const now = Date.now()
|
||||
for (const u of users) {
|
||||
const list = Array.isArray(u.passkeyRecoveryTokens) ? u.passkeyRecoveryTokens : []
|
||||
const match = list.find(t =>
|
||||
t &&
|
||||
t.tokenHash === tokenHash &&
|
||||
!t.usedAt &&
|
||||
t.expiresAt &&
|
||||
new Date(t.expiresAt).getTime() > now
|
||||
)
|
||||
if (match) return { user: u, tokenEntry: match }
|
||||
}
|
||||
return { user: null, tokenEntry: null }
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event)
|
||||
const token = String(query?.token || '')
|
||||
if (!token) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Token fehlt' })
|
||||
}
|
||||
|
||||
const ip = getClientIp(event)
|
||||
assertRateLimit(event, {
|
||||
name: 'auth:passkey-recovery:options:ip',
|
||||
keyParts: [ip],
|
||||
windowMs: 10 * 60 * 1000,
|
||||
maxAttempts: 60,
|
||||
lockoutMs: 10 * 60 * 1000
|
||||
})
|
||||
|
||||
const tokenHash = hashRecoveryToken(token)
|
||||
assertRateLimit(event, {
|
||||
name: 'auth:passkey-recovery:options:token',
|
||||
keyParts: [tokenHash],
|
||||
windowMs: 10 * 60 * 1000,
|
||||
maxAttempts: 10,
|
||||
lockoutMs: 30 * 60 * 1000
|
||||
})
|
||||
|
||||
const users = await readUsers()
|
||||
const { user } = findUserByTokenHash(users, tokenHash)
|
||||
if (!user) {
|
||||
await writeAuditLog('auth.passkey.recovery.options.failed', { ip, reason: 'invalid_token' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Ungültiger oder abgelaufener Token' })
|
||||
}
|
||||
|
||||
const { rpId, rpName } = getWebAuthnConfig()
|
||||
const excludeCredentials = (Array.isArray(user.passkeys) ? user.passkeys : [])
|
||||
.filter(pk => pk && pk.credentialId)
|
||||
.map(pk => ({ id: pk.credentialId, type: 'public-key', transports: pk.transports || undefined }))
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName,
|
||||
rpID: rpId,
|
||||
userID: new TextEncoder().encode(String(user.id)),
|
||||
userName: user.email,
|
||||
userDisplayName: user.name || user.email,
|
||||
attestationType: 'none',
|
||||
authenticatorSelection: {
|
||||
residentKey: 'preferred',
|
||||
userVerification: 'preferred'
|
||||
},
|
||||
excludeCredentials
|
||||
})
|
||||
|
||||
// Opaques recoveryId für Complete-Request
|
||||
const recoveryId = crypto.randomBytes(16).toString('hex')
|
||||
setPreRegistration(recoveryId, {
|
||||
challenge: options.challenge,
|
||||
tokenHash,
|
||||
userId: user.id
|
||||
})
|
||||
|
||||
await writeAuditLog('auth.passkey.recovery.options.issued', { ip, userId: user.id })
|
||||
|
||||
return { success: true, recoveryId, options }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user