esifawafwu
This commit is contained in:
@@ -16,6 +16,14 @@ import {
|
||||
} from '../../webauthn.mjs';
|
||||
import { getEnableAnonymousAccess } from '../../settings.mjs';
|
||||
|
||||
// Maximum number of passkeys a single anonymous identity may hold
|
||||
const MAX_ANON_PASSKEYS = 4;
|
||||
|
||||
const countPasskeys = async userId => {
|
||||
const rows = await db`SELECT COUNT(*)::int AS n FROM passkey_credentials WHERE user_id = ${userId}`;
|
||||
return rows[0]?.n || 0;
|
||||
};
|
||||
|
||||
export default router => {
|
||||
router.group(/^\/api\/v2\/anon/, group => {
|
||||
|
||||
@@ -426,6 +434,128 @@ export default router => {
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Add Passkey to current anonymous identity ────────────────────────────
|
||||
|
||||
// Resolves the logged-in anonymous user, or null if the session isn't an anon identity
|
||||
const getAnonSessionUserId = async req => {
|
||||
if (!req.session?.id) return null;
|
||||
const rows = await db`SELECT 1 FROM anon_identities WHERE user_id = ${req.session.id} LIMIT 1`;
|
||||
return rows.length > 0 ? req.session.id : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/v2/anon/passkey/add/begin
|
||||
* Registration options for an additional passkey on the current anonymous identity.
|
||||
*/
|
||||
group.post(/\/passkey\/add\/begin$/, async (req, res) => {
|
||||
try {
|
||||
if (!getEnableAnonymousAccess()) {
|
||||
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
|
||||
}
|
||||
|
||||
const userId = await getAnonSessionUserId(req);
|
||||
if (!userId) {
|
||||
return res.json({ success: false, msg: 'Not logged in as anonymous' }, 401);
|
||||
}
|
||||
|
||||
const existing = await db`SELECT credential_id FROM passkey_credentials WHERE user_id = ${userId}`;
|
||||
if (existing.length >= MAX_ANON_PASSKEYS) {
|
||||
return res.json({ success: false, msg: `You can have at most ${MAX_ANON_PASSKEYS} passkeys.` }, 400);
|
||||
}
|
||||
|
||||
const userHandle = base64url(Buffer.from(crypto.getRandomValues(new Uint8Array(16))));
|
||||
const challenge = generateChallenge({ type: 'anon-add', userId });
|
||||
|
||||
const options = buildRegistrationOptions({
|
||||
challenge,
|
||||
userId: userHandle,
|
||||
userName: `anon@${cfg.main?.url?.domain || 'f0ck.dev'}`,
|
||||
displayName: 'Anonymous',
|
||||
rpId: getRpIdFromHost(req.headers.host)
|
||||
});
|
||||
// Stop the authenticator from registering a second copy of a passkey it already holds
|
||||
options.excludeCredentials = existing.map(r => ({ type: 'public-key', id: r.credential_id }));
|
||||
|
||||
return res.json({ success: true, options });
|
||||
} catch (err) {
|
||||
console.error('[ANON_PASSKEY] add/begin error:', err);
|
||||
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v2/anon/passkey/add/finish
|
||||
* Verify attestation and attach the new passkey to the current anonymous identity.
|
||||
*/
|
||||
group.post(/\/passkey\/add\/finish$/, async (req, res) => {
|
||||
try {
|
||||
if (!getEnableAnonymousAccess()) {
|
||||
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
|
||||
}
|
||||
|
||||
const userId = await getAnonSessionUserId(req);
|
||||
if (!userId) {
|
||||
return res.json({ success: false, msg: 'Not logged in as anonymous' }, 401);
|
||||
}
|
||||
if (!req.session.csrf_token || req.headers['x-csrf-token'] !== req.session.csrf_token) {
|
||||
return res.json({ success: false, msg: 'Invalid CSRF token' }, 403);
|
||||
}
|
||||
|
||||
const body = req.post || req.body || {};
|
||||
const { challenge, clientDataJSON, attestationObject, credentialId, hw_fingerprint: hwFingerprint } = body;
|
||||
if (!challenge || !clientDataJSON || !attestationObject || !credentialId) {
|
||||
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
|
||||
}
|
||||
|
||||
let challengeMeta;
|
||||
try {
|
||||
challengeMeta = consumeChallenge(challenge);
|
||||
} catch (e) {
|
||||
return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400);
|
||||
}
|
||||
if (challengeMeta.type !== 'anon-add' || challengeMeta.userId !== userId) {
|
||||
return res.json({ success: false, msg: 'Wrong challenge type' }, 400);
|
||||
}
|
||||
|
||||
// Re-check here too: two add flows could have been started in parallel
|
||||
if (await countPasskeys(userId) >= MAX_ANON_PASSKEYS) {
|
||||
return res.json({ success: false, msg: `You can have at most ${MAX_ANON_PASSKEYS} passkeys.` }, 400);
|
||||
}
|
||||
|
||||
let regResult;
|
||||
try {
|
||||
regResult = await verifyRegistration({ challenge, clientDataJSON, attestationObject, credentialId, rpId: getRpIdFromHost(req.headers.host) });
|
||||
} catch (e) {
|
||||
console.warn('[ANON_PASSKEY] Add verification failed:', e.message);
|
||||
return res.json({ success: false, msg: `Registration failed: ${e.message}` }, 400);
|
||||
}
|
||||
|
||||
const taken = await db`SELECT user_id FROM passkey_credentials WHERE credential_id = ${credentialId} LIMIT 1`;
|
||||
if (taken.length > 0) {
|
||||
return res.json({ success: false, msg: 'This passkey is already registered.' }, 409);
|
||||
}
|
||||
|
||||
const fingerprint = 'SHA256:' + crypto.createHash('sha256').update(Buffer.from(credentialId)).digest().toString('base64').replace(/=+$/, '');
|
||||
const auditIp = resolveAuditIP(req);
|
||||
|
||||
await db`
|
||||
INSERT INTO passkey_credentials (user_id, credential_id, public_key_spki, sign_count, aaguid, name)
|
||||
VALUES (${userId}, ${credentialId}, ${regResult.spki}, ${regResult.signCount}, ${regResult.aaguid || null}, ${'Passkey'})
|
||||
`;
|
||||
await db`
|
||||
INSERT INTO anon_identities (user_id, credential_id, fingerprint, created_ip, last_ip, hw_fingerprint)
|
||||
VALUES (${userId}, ${credentialId}, ${fingerprint}, ${auditIp}, ${auditIp}, ${hwFingerprint || null})
|
||||
ON CONFLICT (credential_id) DO NOTHING
|
||||
`;
|
||||
|
||||
const passkeyCount = await countPasskeys(userId);
|
||||
return res.json({ success: true, passkey_count: passkeyCount, passkey_max: MAX_ANON_PASSKEYS });
|
||||
} catch (err) {
|
||||
console.error('[ANON_PASSKEY] add/finish error:', err);
|
||||
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Identity ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -448,6 +578,7 @@ export default router => {
|
||||
FROM anon_identities ai
|
||||
LEFT JOIN passkey_credentials pc ON pc.credential_id = ai.credential_id
|
||||
WHERE ai.user_id = ${req.session.id}
|
||||
ORDER BY ai.created_at ASC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
@@ -462,6 +593,8 @@ export default router => {
|
||||
hw_fingerprint: rows[0].hw_fingerprint,
|
||||
credential_id: rows[0].credential_id,
|
||||
passkey_name: rows[0].passkey_name,
|
||||
passkey_count: await countPasskeys(req.session.id),
|
||||
passkey_max: MAX_ANON_PASSKEYS,
|
||||
csrf_token: req.session.csrf_token
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user