feat(security): Session-Cookies statt X-User-Id und API-Härtung

Ersetzt die spoofbare X-User-Id-Auth durch signierte HttpOnly-Sessions nach
WebAuthn, erzwingt WRITE-only Sync, speichert den Master-Key nur im RAM und
ergänzt CORS, Rate-Limits, Helmet sowie Passkey-Reauth für sensible Aktionen.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-30 13:47:24 +02:00
parent 4f3f530f1f
commit dea33e3f00
33 changed files with 657 additions and 397 deletions
+119 -37
View File
@@ -6,6 +6,14 @@ import {
verifyAuthenticationResponse
} from '@simplewebauthn/server'
import { prisma } from '../db.js'
import { requireReauth, requireUser } from '../middleware/auth.js'
import {
clearSessionCookie,
extendReauth,
readSessionFromRequest,
setSessionCookie,
setSessionTokenCookie
} from '../session.js'
const router = Router()
@@ -13,12 +21,9 @@ const rpName = 'Kapteins Daagbok'
const rpID = process.env.RP_ID || 'localhost'
const origin = process.env.ORIGIN || 'http://localhost:5173'
// In-memory challenge stores
const registrationChallenges = new Map<string, string>()
const authenticationChallenges = new Map<string, { challenge: string; userId: string }>()
const activeChallenges = new Set<string>()
// 1. Generate Registration Options
router.post('/register-options', async (req, res) => {
try {
const { username } = req.body
@@ -34,13 +39,6 @@ router.post('/register-options', async (req, res) => {
return res.status(400).json({ error: 'User already exists' })
}
// NOTE: @simplewebauthn/server v9 places `userID` verbatim into the
// emitted `user.id` JSON field. The browser client (v13) however decodes
// `user.id` as a base64url string. Passing a raw username therefore either
// corrupts the user handle or, for usernames containing characters outside
// the base64url alphabet (".", " ", "@", umlauts, ...), makes the browser
// throw "Invalid character" before the passkey prompt even appears.
// Encoding the username as base64url keeps the value spec-compliant.
const userID = Buffer.from(username, 'utf8').toString('base64url')
const options = await generateRegistrationOptions({
@@ -54,10 +52,9 @@ router.post('/register-options', async (req, res) => {
residentKey: 'required',
userVerification: 'preferred'
},
supportedAlgorithmIDs: [-7, -257] // ES256 and RS256
supportedAlgorithmIDs: [-7, -257]
})
// Store challenge
registrationChallenges.set(username, options.challenge)
return res.json(options)
@@ -67,7 +64,6 @@ router.post('/register-options', async (req, res) => {
}
})
// 2. Verify Registration Response
router.post('/register-verify', async (req, res) => {
try {
const {
@@ -103,7 +99,6 @@ router.post('/register-verify', async (req, res) => {
const { credentialID, credentialPublicKey, counter } = verification.registrationInfo
// Save user and credential
const user = await prisma.user.create({
data: {
username,
@@ -125,6 +120,7 @@ router.post('/register-verify', async (req, res) => {
})
registrationChallenges.delete(username)
setSessionCookie(res, user.id, true)
return res.json({ verified: true, userId: user.id })
} catch (error: any) {
@@ -133,12 +129,10 @@ router.post('/register-verify', async (req, res) => {
}
})
// 3. Generate Authentication Options
router.post('/login-options', async (req, res) => {
try {
const { username } = req.body
// If username is supplied, we do a targeted login, otherwise usernameless
let allowCredentials: any[] = []
if (username) {
const user = await prisma.user.findUnique({
@@ -146,7 +140,7 @@ router.post('/login-options', async (req, res) => {
include: { credentials: true }
})
if (user) {
allowCredentials = user.credentials.map(cred => ({
allowCredentials = user.credentials.map((cred) => ({
id: Buffer.from(cred.credentialId, 'base64url'),
type: 'public-key',
transports: cred.transports as any[]
@@ -160,7 +154,6 @@ router.post('/login-options', async (req, res) => {
userVerification: 'preferred'
})
// Store challenge
activeChallenges.add(options.challenge)
return res.json(options)
@@ -170,7 +163,6 @@ router.post('/login-options', async (req, res) => {
}
})
// 4. Verify Authentication Response
router.post('/login-verify', async (req, res) => {
try {
const { credentialResponse, challenge } = req.body
@@ -178,13 +170,11 @@ router.post('/login-verify', async (req, res) => {
return res.status(400).json({ error: 'credentialResponse and challenge are required' })
}
// Verify challenge
if (!activeChallenges.has(challenge)) {
return res.status(400).json({ error: 'Challenge not found or expired' })
}
activeChallenges.delete(challenge)
// Find the credential in DB
const dbCred = await prisma.credential.findUnique({
where: { credentialId: credentialResponse.id },
include: { user: true }
@@ -212,12 +202,13 @@ router.post('/login-verify', async (req, res) => {
return res.status(400).json({ error: 'Authentication failed' })
}
// Update counter
await prisma.credential.update({
where: { id: dbCred.id },
data: { counter: BigInt(verification.authenticationInfo.newCounter) }
})
setSessionCookie(res, user.id, true)
return res.json({
verified: true,
userId: user.id,
@@ -235,16 +226,112 @@ router.post('/login-verify', async (req, res) => {
}
})
// 5. Delete own account
router.delete('/delete-account', async (req: any, res) => {
router.get('/session', (req, res) => {
const session = readSessionFromRequest(req)
if (!session) {
return res.status(401).json({ authenticated: false })
}
return res.json({ authenticated: true, userId: session.userId })
})
router.post('/logout', (req, res) => {
clearSessionCookie(res)
return res.json({ success: true })
})
router.post('/reauth-options', requireUser, async (req: any, res) => {
try {
const userId = req.headers['x-user-id']
if (!userId) {
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
const user = await prisma.user.findUnique({
where: { id: req.userId },
include: { credentials: true }
})
if (!user || user.credentials.length === 0) {
return res.status(400).json({ error: 'No passkey credentials found' })
}
const allowCredentials = user.credentials.map((cred) => ({
id: Buffer.from(cred.credentialId, 'base64url'),
type: 'public-key' as const,
transports: cred.transports as any[]
}))
const options = await generateAuthenticationOptions({
rpID,
allowCredentials,
userVerification: 'required'
})
activeChallenges.add(options.challenge)
return res.json(options)
} catch (error: any) {
console.error('Error generating reauth options:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
router.post('/reauth-verify', requireUser, async (req: any, res) => {
try {
const { credentialResponse, challenge } = req.body
if (!credentialResponse || !challenge) {
return res.status(400).json({ error: 'credentialResponse and challenge are required' })
}
if (!activeChallenges.has(challenge)) {
return res.status(400).json({ error: 'Challenge not found or expired' })
}
activeChallenges.delete(challenge)
const dbCred = await prisma.credential.findUnique({
where: { credentialId: credentialResponse.id },
include: { user: true }
})
if (!dbCred || dbCred.userId !== req.userId) {
return res.status(403).json({ error: 'Credential does not belong to this account' })
}
const verification = await verifyAuthenticationResponse({
response: credentialResponse,
expectedChallenge: challenge,
expectedOrigin: origin,
expectedRPID: rpID,
authenticator: {
credentialID: Buffer.from(dbCred.credentialId, 'base64url'),
credentialPublicKey: dbCred.publicKey,
counter: Number(dbCred.counter)
}
})
if (!verification.verified || !verification.authenticationInfo) {
return res.status(400).json({ error: 'Reauthentication failed' })
}
await prisma.credential.update({
where: { id: dbCred.id },
data: { counter: BigInt(verification.authenticationInfo.newCounter) }
})
const currentToken = req.cookies?.daagbok_session
const extended = typeof currentToken === 'string' ? extendReauth(currentToken) : null
if (extended) {
setSessionTokenCookie(res, extended)
} else {
setSessionCookie(res, req.userId, true)
}
return res.json({ verified: true })
} catch (error: any) {
console.error('Error verifying reauth:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
router.delete('/delete-account', requireReauth, async (req: any, res) => {
try {
const user = await prisma.user.findUnique({
where: { id: userId }
where: { id: req.userId }
})
if (!user) {
@@ -252,9 +339,10 @@ router.delete('/delete-account', async (req: any, res) => {
}
await prisma.user.delete({
where: { id: userId }
where: { id: req.userId }
})
clearSessionCookie(res)
return res.json({ success: true })
} catch (error: any) {
console.error('Error deleting account:', error)
@@ -262,14 +350,8 @@ router.delete('/delete-account', async (req: any, res) => {
}
})
// 6. Enroll PRF encrypted master key
router.post('/enroll-prf', async (req: any, res) => {
router.post('/enroll-prf', requireReauth, async (req: any, res) => {
try {
const userId = req.headers['x-user-id']
if (!userId) {
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
}
const { encryptedMasterKeyPrf, encryptedMasterKeyPrfIv, encryptedMasterKeyPrfTag } = req.body
if (!encryptedMasterKeyPrf || !encryptedMasterKeyPrfIv || !encryptedMasterKeyPrfTag) {
return res.status(400).json({ error: 'Missing required PRF key fields' })
@@ -284,7 +366,7 @@ router.post('/enroll-prf', async (req: any, res) => {
}
await prisma.user.update({
where: { id: userId },
where: { id: req.userId },
data: {
encryptedMasterKeyPrf,
encryptedMasterKeyPrfIv,