fix(auth): Add-credential-Challenges pro Versuch und single-use
Speichert Challenges nach challenge statt userId für parallele Flows und invalidiert sie vor der Verifikation, damit fehlgeschlagene Versuche keine Leaks hinterlassen. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -613,7 +613,7 @@ export async function addPasskey(): Promise<void> {
|
||||
|
||||
await apiJson(`${API_BASE}/add-credential-verify`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ credentialResponse })
|
||||
body: JSON.stringify({ credentialResponse, challenge: options.challenge })
|
||||
})
|
||||
|
||||
const masterKey = getActiveMasterKey()
|
||||
|
||||
@@ -22,6 +22,7 @@ const rpID = process.env.RP_ID || 'localhost'
|
||||
const origin = process.env.ORIGIN || 'http://localhost:5173'
|
||||
|
||||
const registrationChallenges = new Map<string, string>()
|
||||
/** WebAuthn registration challenges for add-credential flow: challenge -> userId */
|
||||
const addCredentialChallenges = new Map<string, string>()
|
||||
const activeChallenges = new Set<string>()
|
||||
|
||||
@@ -462,7 +463,7 @@ router.post('/add-credential-options', requireReauth, async (req: any, res) => {
|
||||
excludeCredentials
|
||||
})
|
||||
|
||||
addCredentialChallenges.set(req.userId, options.challenge)
|
||||
addCredentialChallenges.set(options.challenge, req.userId)
|
||||
|
||||
return res.json(options)
|
||||
} catch (error: any) {
|
||||
@@ -473,16 +474,23 @@ router.post('/add-credential-options', requireReauth, async (req: any, res) => {
|
||||
|
||||
router.post('/add-credential-verify', requireReauth, async (req: any, res) => {
|
||||
try {
|
||||
const { credentialResponse } = req.body
|
||||
if (!credentialResponse) {
|
||||
return res.status(400).json({ error: 'credentialResponse is required' })
|
||||
const { credentialResponse, challenge } = req.body
|
||||
if (!credentialResponse || !challenge) {
|
||||
return res.status(400).json({ error: 'credentialResponse and challenge are required' })
|
||||
}
|
||||
|
||||
const expectedChallenge = addCredentialChallenges.get(req.userId)
|
||||
if (!expectedChallenge) {
|
||||
const challengeUserId = addCredentialChallenges.get(challenge)
|
||||
if (!challengeUserId) {
|
||||
return res.status(400).json({ error: 'Challenge not found or expired' })
|
||||
}
|
||||
|
||||
if (challengeUserId !== req.userId) {
|
||||
return res.status(403).json({ error: 'Challenge does not belong to this account' })
|
||||
}
|
||||
|
||||
// Single-use: invalidate before verification so failed attempts cannot be retried
|
||||
addCredentialChallenges.delete(challenge)
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.userId }
|
||||
})
|
||||
@@ -493,7 +501,7 @@ router.post('/add-credential-verify', requireReauth, async (req: any, res) => {
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response: credentialResponse,
|
||||
expectedChallenge,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID
|
||||
})
|
||||
@@ -522,8 +530,6 @@ router.post('/add-credential-verify', requireReauth, async (req: any, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
addCredentialChallenges.delete(req.userId)
|
||||
|
||||
return res.json({
|
||||
verified: true,
|
||||
credential: {
|
||||
|
||||
Reference in New Issue
Block a user