639 lines
26 KiB
JavaScript
639 lines
26 KiB
JavaScript
import crypto from 'node:crypto';
|
|
import db from '../../sql.mjs';
|
|
import lib from '../../lib.mjs';
|
|
import cfg from '../../config.mjs';
|
|
import security from '../../security.mjs';
|
|
import {
|
|
getOrCreateAnonUserByCredential,
|
|
createAnonSession,
|
|
resolveAuditIP
|
|
} from '../../anon_auth.mjs';
|
|
import {
|
|
generateChallenge, consumeChallenge,
|
|
verifyRegistration, verifyAuthentication,
|
|
buildRegistrationOptions, buildAuthenticationOptions,
|
|
base64url, fromBase64url, getRpIdFromHost
|
|
} 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 => {
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
|
|
const formatCascadeReason = (sourceReason, prefix = 'Cascade ban from device') => {
|
|
if (!sourceReason) return prefix;
|
|
let clean = sourceReason;
|
|
while (/^Cascade ban from (device|hardware ID|key) \((.*)\)$/.test(clean)) {
|
|
clean = clean.replace(/^Cascade ban from (device|hardware ID|key) \((.*)\)$/, '$2');
|
|
}
|
|
return `${prefix} (${clean || 'Violation of community rules'})`;
|
|
};
|
|
|
|
const setBanCookie = (res, reason, expires) => {
|
|
const payload = encodeURIComponent(JSON.stringify({
|
|
banned: true,
|
|
reason: reason || 'Banned',
|
|
expires: expires ? new Date(expires).toISOString() : null
|
|
}));
|
|
res.setHeader('Set-Cookie', `f0ck_banned=${payload}; Path=/; Max-Age=31536000; SameSite=Lax`);
|
|
};
|
|
|
|
const checkAndCascadeBans = async (res, fingerprint, hwFingerprint, credentialId, tombstone) => {
|
|
// Tombstone cascade
|
|
if (tombstone && tombstone.banned) {
|
|
const tombstoneFp = tombstone.fingerprint;
|
|
const tombstoneHw = tombstone.hw_fingerprint;
|
|
const tombstoneBan = tombstoneFp ? await security.isFingerprintBanned(tombstoneFp) : null;
|
|
const tombstoneHwBan = (!tombstoneBan && tombstoneHw) ? await security.isHardwareBanned(tombstoneHw) : null;
|
|
const activeTombstoneBan = tombstoneBan || tombstoneHwBan;
|
|
if (activeTombstoneBan) {
|
|
const alreadyFpBanned = fingerprint ? await security.isFingerprintBanned(fingerprint) : false;
|
|
if (!alreadyFpBanned && fingerprint) {
|
|
await security.banAnonymousUser({
|
|
fingerprint,
|
|
hwFingerprint: hwFingerprint || tombstoneHw,
|
|
bannedBy: activeTombstoneBan.banned_by,
|
|
reason: formatCascadeReason(activeTombstoneBan.reason, 'Cascade ban from device'),
|
|
expires: activeTombstoneBan.expires,
|
|
banIps: true,
|
|
banHardware: true
|
|
});
|
|
}
|
|
return activeTombstoneBan;
|
|
}
|
|
}
|
|
|
|
// Hardware fingerprint ban
|
|
if (hwFingerprint) {
|
|
const hwBan = await security.isHardwareBanned(hwFingerprint);
|
|
if (hwBan) {
|
|
const alreadyFpBanned = fingerprint ? await security.isFingerprintBanned(fingerprint) : false;
|
|
if (!alreadyFpBanned && fingerprint) {
|
|
await security.banAnonymousUser({
|
|
fingerprint,
|
|
hwFingerprint,
|
|
bannedBy: hwBan.banned_by,
|
|
reason: formatCascadeReason(hwBan.reason, 'Cascade ban from hardware ID'),
|
|
expires: hwBan.expires,
|
|
banIps: true,
|
|
banHardware: true
|
|
});
|
|
}
|
|
return hwBan;
|
|
}
|
|
}
|
|
|
|
// Fingerprint ban
|
|
if (fingerprint) {
|
|
const fpBan = await security.isFingerprintBanned(fingerprint);
|
|
if (fpBan) {
|
|
if (hwFingerprint) {
|
|
const alreadyHwBanned = await security.isHardwareBanned(hwFingerprint);
|
|
if (!alreadyHwBanned) {
|
|
await security.banAnonymousUser({
|
|
fingerprint,
|
|
hwFingerprint,
|
|
bannedBy: fpBan.banned_by,
|
|
reason: formatCascadeReason(fpBan.reason, 'Cascade ban from key'),
|
|
expires: fpBan.expires,
|
|
banIps: true,
|
|
banHardware: true
|
|
});
|
|
}
|
|
}
|
|
return fpBan;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
// ─── Deprecated SSH endpoint — hard cut ───────────────────────────────────
|
|
|
|
group.post(/\/session$/, async (req, res) => {
|
|
return res.json({
|
|
success: false,
|
|
msg: 'SSH-key anonymous authentication has been replaced by passkeys. Please refresh the page.'
|
|
}, 410);
|
|
});
|
|
|
|
// ─── Passkey Registration ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* POST /api/v2/anon/passkey/register/begin
|
|
* Returns WebAuthn registration options (challenge + rp + user config).
|
|
* The client does NOT need to be logged in.
|
|
*/
|
|
group.post(/\/passkey\/register\/begin$/, async (req, res) => {
|
|
try {
|
|
if (!getEnableAnonymousAccess()) {
|
|
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
|
|
}
|
|
|
|
const clientIp = security.getRealIP(req);
|
|
const ipBan = await security.isIpBanned(clientIp);
|
|
if (ipBan) {
|
|
setBanCookie(res, ipBan.reason || 'IP address is banned', ipBan.expires);
|
|
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: ipBan.reason, redirect: '/banned' }, 403);
|
|
}
|
|
|
|
const challenge = generateChallenge({ type: 'anon-register' });
|
|
|
|
// Generate a temporary opaque user handle (32 random bytes, base64url)
|
|
// This will be replaced by the real user_id after finish, but WebAuthn requires
|
|
// a user.id at registration time. We store it in the challenge.
|
|
const userHandle = base64url(Buffer.from(crypto.getRandomValues(new Uint8Array(16))));
|
|
// Store the user handle in the challenge so finish can retrieve it
|
|
// (The challenge entry is keyed by challenge string)
|
|
// We re-issue the challenge with the user handle attached
|
|
const challengeWithHandle = generateChallenge({ type: 'anon-register', userHandle });
|
|
|
|
// Temporary display name for the registration prompt
|
|
const tmpName = `anon_new@${cfg.main?.url?.domain || 'f0ck.dev'}`;
|
|
|
|
const options = buildRegistrationOptions({
|
|
challenge: challengeWithHandle,
|
|
userId: userHandle,
|
|
userName: tmpName,
|
|
displayName: 'Anonymous',
|
|
rpId: getRpIdFromHost(req.headers.host)
|
|
});
|
|
|
|
return res.json({ success: true, options });
|
|
} catch (err) {
|
|
console.error('[ANON_PASSKEY] register/begin error:', err);
|
|
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/v2/anon/passkey/register/finish
|
|
* Verify attestation, create shadow user + credential, establish session.
|
|
*/
|
|
group.post(/\/passkey\/register\/finish$/, async (req, res) => {
|
|
try {
|
|
if (!getEnableAnonymousAccess()) {
|
|
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
|
|
}
|
|
|
|
const clientIp = security.getRealIP(req);
|
|
const ipBan = await security.isIpBanned(clientIp);
|
|
if (ipBan) {
|
|
setBanCookie(res, ipBan.reason || 'IP address is banned', ipBan.expires);
|
|
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: ipBan.reason, redirect: '/banned' }, 403);
|
|
}
|
|
|
|
const body = req.post || req.body || {};
|
|
const { challenge, clientDataJSON, attestationObject, credentialId, hw_fingerprint: hwFingerprint, tombstone } = body;
|
|
|
|
if (!challenge || !clientDataJSON || !attestationObject || !credentialId) {
|
|
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
|
|
}
|
|
|
|
// Consume and verify challenge
|
|
let challengeMeta;
|
|
try {
|
|
challengeMeta = consumeChallenge(challenge);
|
|
} catch (e) {
|
|
return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400);
|
|
}
|
|
if (challengeMeta.type !== 'anon-register') {
|
|
return res.json({ success: false, msg: 'Wrong challenge type' }, 400);
|
|
}
|
|
|
|
// Verify the attestation
|
|
let regResult;
|
|
try {
|
|
regResult = await verifyRegistration({ challenge, clientDataJSON, attestationObject, credentialId, rpId: getRpIdFromHost(req.headers.host) });
|
|
} catch (e) {
|
|
console.warn('[ANON_PASSKEY] Registration verification failed:', e.message);
|
|
return res.json({ success: false, msg: `Registration failed: ${e.message}` }, 400);
|
|
}
|
|
|
|
// Get fingerprint before ban checks (derived from credentialId)
|
|
const fingerprint = 'SHA256:' + crypto.createHash('sha256').update(Buffer.from(credentialId)).digest().toString('base64').replace(/=+$/, '');
|
|
|
|
// Ban checks
|
|
const ban = await checkAndCascadeBans(res, fingerprint, hwFingerprint || null, credentialId, tombstone || null);
|
|
if (ban) {
|
|
setBanCookie(res, ban.reason || 'Banned', ban.expires);
|
|
return res.json({
|
|
success: false, banned: true,
|
|
fingerprint, hw_fingerprint: hwFingerprint,
|
|
msg: 'YOU ARE BANNED!', reason: ban.reason,
|
|
expires: ban.expires ? new Date(ban.expires).toLocaleString() : 'Permanent',
|
|
redirect: '/banned'
|
|
}, 403);
|
|
}
|
|
|
|
// Get or create shadow user
|
|
const { userId, isNew, fingerprint: fp } = await getOrCreateAnonUserByCredential(
|
|
credentialId, req, hwFingerprint || null
|
|
);
|
|
|
|
// Check user table ban
|
|
const userRows = await db`SELECT banned, ban_reason, ban_expires FROM "user" WHERE id = ${userId} LIMIT 1`;
|
|
if (userRows.length > 0 && userRows[0].banned) {
|
|
const u = userRows[0];
|
|
setBanCookie(res, u.ban_reason || 'Banned', u.ban_expires);
|
|
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: u.ban_reason, redirect: '/banned' }, 403);
|
|
}
|
|
|
|
// Store / update passkey credential in passkey_credentials
|
|
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'})
|
|
ON CONFLICT (credential_id) DO UPDATE
|
|
SET sign_count = ${regResult.signCount}, last_used = NOW()
|
|
`;
|
|
|
|
const { session, csrf_token } = await createAnonSession(userId, req, hwFingerprint || null);
|
|
res.setHeader('Set-Cookie', `session=${session}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`);
|
|
|
|
return res.json({
|
|
success: true,
|
|
is_new: isNew,
|
|
user_id: userId,
|
|
fingerprint: fp,
|
|
short_fingerprint: fp.slice(7, 15),
|
|
credential_id: credentialId,
|
|
hw_fingerprint: hwFingerprint || null,
|
|
csrf_token
|
|
});
|
|
} catch (err) {
|
|
console.error('[ANON_PASSKEY] register/finish error:', err);
|
|
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
|
|
}
|
|
});
|
|
|
|
// ─── Passkey Authentication ───────────────────────────────────────────────
|
|
|
|
/**
|
|
* POST /api/v2/anon/passkey/auth/begin
|
|
* Returns authentication options. allowCredentials is empty (discoverable credential flow).
|
|
*/
|
|
group.post(/\/passkey\/auth\/begin$/, async (req, res) => {
|
|
try {
|
|
if (!getEnableAnonymousAccess()) {
|
|
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
|
|
}
|
|
|
|
const clientIp = security.getRealIP(req);
|
|
const ipBan = await security.isIpBanned(clientIp);
|
|
if (ipBan) {
|
|
setBanCookie(res, ipBan.reason || 'IP address is banned', ipBan.expires);
|
|
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: ipBan.reason, redirect: '/banned' }, 403);
|
|
}
|
|
|
|
const challenge = generateChallenge({ type: 'anon-auth' });
|
|
const options = buildAuthenticationOptions({
|
|
challenge,
|
|
allowCredentials: [], // discoverable — let the browser/Bitwarden pick
|
|
rpId: getRpIdFromHost(req.headers.host)
|
|
});
|
|
|
|
return res.json({ success: true, options });
|
|
} catch (err) {
|
|
console.error('[ANON_PASSKEY] auth/begin error:', err);
|
|
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/v2/anon/passkey/auth/finish
|
|
* Verify assertion, establish anonymous session.
|
|
*/
|
|
group.post(/\/passkey\/auth\/finish$/, async (req, res) => {
|
|
try {
|
|
if (!getEnableAnonymousAccess()) {
|
|
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
|
|
}
|
|
|
|
const clientIp = security.getRealIP(req);
|
|
const ipBan = await security.isIpBanned(clientIp);
|
|
if (ipBan) {
|
|
setBanCookie(res, ipBan.reason || 'IP address is banned', ipBan.expires);
|
|
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: ipBan.reason, redirect: '/banned' }, 403);
|
|
}
|
|
|
|
const body = req.post || req.body || {};
|
|
const { challenge, clientDataJSON, authenticatorData, signature, credentialId, hw_fingerprint: hwFingerprint, tombstone } = body;
|
|
|
|
if (!challenge || !clientDataJSON || !authenticatorData || !signature || !credentialId) {
|
|
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
|
|
}
|
|
|
|
// Consume challenge
|
|
let challengeMeta;
|
|
try {
|
|
challengeMeta = consumeChallenge(challenge);
|
|
} catch (e) {
|
|
return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400);
|
|
}
|
|
if (challengeMeta.type !== 'anon-auth') {
|
|
return res.json({ success: false, msg: 'Wrong challenge type' }, 400);
|
|
}
|
|
|
|
// Look up stored credential
|
|
const credRows = await db`
|
|
SELECT pc.user_id, pc.public_key_spki, pc.sign_count, ai.fingerprint
|
|
FROM passkey_credentials pc
|
|
LEFT JOIN anon_identities ai ON ai.user_id = pc.user_id AND ai.credential_id = ${credentialId}
|
|
WHERE pc.credential_id = ${credentialId}
|
|
LIMIT 1
|
|
`;
|
|
|
|
if (credRows.length === 0) {
|
|
return res.json({ success: false, msg: 'Passkey not registered. Please register first.' }, 401);
|
|
}
|
|
|
|
const { user_id: userId, public_key_spki: spki, sign_count: storedSignCount, fingerprint } = credRows[0];
|
|
|
|
// Verify the assertion
|
|
let authResult;
|
|
try {
|
|
authResult = await verifyAuthentication({
|
|
challenge,
|
|
clientDataJSON,
|
|
authenticatorData,
|
|
signature,
|
|
spki,
|
|
storedSignCount,
|
|
rpId: getRpIdFromHost(req.headers.host)
|
|
});
|
|
} catch (e) {
|
|
console.warn('[ANON_PASSKEY] Auth verification failed:', e.message);
|
|
return res.json({ success: false, msg: `Authentication failed: ${e.message}` }, 401);
|
|
}
|
|
|
|
// Derive fingerprint if not stored yet (legacy or first-time)
|
|
const fpForBan = fingerprint || (() => {
|
|
return 'SHA256:' + crypto.createHash('sha256').update(Buffer.from(credentialId)).digest().toString('base64').replace(/=+$/, '');
|
|
})();
|
|
|
|
// Ban checks
|
|
const ban = await checkAndCascadeBans(res, fpForBan, hwFingerprint || null, credentialId, tombstone || null);
|
|
if (ban) {
|
|
setBanCookie(res, ban.reason || 'Banned', ban.expires);
|
|
return res.json({
|
|
success: false, banned: true,
|
|
fingerprint: fpForBan, hw_fingerprint: hwFingerprint,
|
|
msg: 'YOU ARE BANNED!', reason: ban.reason,
|
|
expires: ban.expires ? new Date(ban.expires).toLocaleString() : 'Permanent',
|
|
redirect: '/banned'
|
|
}, 403);
|
|
}
|
|
|
|
// Check user table ban
|
|
const userRows = await db`SELECT banned, ban_reason, ban_expires FROM "user" WHERE id = ${userId} LIMIT 1`;
|
|
if (userRows.length > 0 && userRows[0].banned) {
|
|
const u = userRows[0];
|
|
setBanCookie(res, u.ban_reason || 'Banned', u.ban_expires);
|
|
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: u.ban_reason, redirect: '/banned' }, 403);
|
|
}
|
|
|
|
// Update sign count and last_used
|
|
await db`
|
|
UPDATE passkey_credentials
|
|
SET sign_count = ${authResult.newSignCount}, last_used = NOW()
|
|
WHERE credential_id = ${credentialId}
|
|
`;
|
|
|
|
// Update anon_identities (hw_fingerprint, last_seen)
|
|
await db`
|
|
UPDATE anon_identities
|
|
SET last_seen = NOW()
|
|
${hwFingerprint ? db`, hw_fingerprint = ${hwFingerprint}` : db``}
|
|
WHERE user_id = ${userId} AND credential_id = ${credentialId}
|
|
`.catch(() => {});
|
|
|
|
const { session, csrf_token } = await createAnonSession(userId, req, hwFingerprint || null);
|
|
res.setHeader('Set-Cookie', `session=${session}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`);
|
|
|
|
return res.json({
|
|
success: true,
|
|
user_id: userId,
|
|
fingerprint: fpForBan,
|
|
short_fingerprint: fpForBan.slice(7, 15),
|
|
credential_id: credentialId,
|
|
hw_fingerprint: hwFingerprint || null,
|
|
csrf_token
|
|
});
|
|
} catch (err) {
|
|
console.error('[ANON_PASSKEY] auth/finish error:', err);
|
|
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
|
|
}
|
|
});
|
|
|
|
// ─── 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 ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* GET /api/v2/anon/identity
|
|
* Get the current anonymous identity or registered user state.
|
|
*/
|
|
group.get(/\/identity$/, async (req, res) => {
|
|
try {
|
|
if (!getEnableAnonymousAccess()) {
|
|
return res.json({ logged_in: false, is_anon: false, disabled: true });
|
|
}
|
|
|
|
if (!req.session) {
|
|
return res.json({ logged_in: false, is_anon: false });
|
|
}
|
|
|
|
const rows = await db`
|
|
SELECT ai.credential_id, ai.fingerprint, ai.hw_fingerprint, ai.created_at, ai.last_seen,
|
|
pc.name AS passkey_name, pc.aaguid
|
|
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
|
|
`;
|
|
|
|
if (rows.length > 0) {
|
|
const fp = rows[0].fingerprint;
|
|
return res.json({
|
|
logged_in: true,
|
|
is_anon: true,
|
|
user_id: req.session.id,
|
|
fingerprint: fp,
|
|
short_fingerprint: fp ? fp.slice(7, 15) : null,
|
|
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
|
|
});
|
|
}
|
|
|
|
return res.json({
|
|
logged_in: true,
|
|
is_anon: false,
|
|
user: req.session.user,
|
|
user_id: req.session.id,
|
|
csrf_token: req.session.csrf_token
|
|
});
|
|
} catch (err) {
|
|
console.error('[ANON_PASSKEY] Identity lookup error:', err);
|
|
return res.json({ success: false, msg: err.message }, 500);
|
|
}
|
|
});
|
|
|
|
// ─── Logout ───────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* POST /api/v2/anon/logout
|
|
* Clear anonymous session cookie and remove active session from database.
|
|
*/
|
|
group.post(/\/logout$/, async (req, res) => {
|
|
try {
|
|
if (req.session && req.session.sess_id) {
|
|
await db`
|
|
DELETE FROM user_sessions
|
|
WHERE id = ${+req.session.sess_id}
|
|
`;
|
|
}
|
|
res.setHeader('Set-Cookie', `session=; ${lib.getCookieOptions('Thu, 01 Jan 1970 00:00:00 GMT')}`);
|
|
return res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('[ANON_PASSKEY] Logout error:', err);
|
|
return res.json({ success: false, msg: err.message }, 500);
|
|
}
|
|
});
|
|
|
|
});
|
|
};
|