alotta good shit
This commit is contained in:
@@ -5,6 +5,12 @@ import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { canAnonDo, isAnonSession } from '../../settings.mjs';
|
||||
import {
|
||||
generateChallenge, consumeChallenge,
|
||||
verifyRegistration, verifyAuthentication,
|
||||
buildRegistrationOptions, buildAuthenticationOptions,
|
||||
base64url, getRpIdFromHost
|
||||
} from '../../webauthn.mjs';
|
||||
|
||||
// Note: Avatar upload/delete is handled by middleware in index.mjs via avatar_handler.mjs
|
||||
// These routes remain for other settings API endpoints
|
||||
@@ -1194,6 +1200,274 @@ export default router => {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ─── Passkey Management (registered users) ──────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/v2/settings/passkeys
|
||||
* List the current user's registered passkeys.
|
||||
*/
|
||||
group.get(/\/passkeys$/, lib.registeredUser, async (req, res) => {
|
||||
try {
|
||||
const rows = await db`
|
||||
SELECT id, credential_id, name, aaguid, created_at, last_used
|
||||
FROM passkey_credentials
|
||||
WHERE user_id = ${req.session.id}
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
return res.json({ success: true, passkeys: rows });
|
||||
} catch (err) {
|
||||
console.error('[PASSKEYS] List error:', err);
|
||||
return res.json({ success: false, msg: err.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v2/settings/passkeys/register/begin
|
||||
* Generate WebAuthn registration options for a logged-in user.
|
||||
*/
|
||||
group.post(/\/passkeys\/register\/begin$/, lib.registeredUser, async (req, res) => {
|
||||
try {
|
||||
// Get existing credentials to exclude (prevent re-registering same authenticator)
|
||||
const existing = await db`
|
||||
SELECT credential_id FROM passkey_credentials
|
||||
WHERE user_id = ${req.session.id}
|
||||
`;
|
||||
const excludeCredentials = existing.map(r => ({ id: r.credential_id, type: 'public-key' }));
|
||||
|
||||
const challenge = generateChallenge({ type: 'user-register', userId: req.session.id });
|
||||
|
||||
// User handle: SHA256 of user_id encoded as base64url (stable, opaque)
|
||||
const userHandle = base64url(
|
||||
crypto.createHash('sha256').update(String(req.session.id)).digest().slice(0, 16)
|
||||
);
|
||||
|
||||
const body = req.post || req.body || {};
|
||||
const passkeyName = (body.name || '').trim().slice(0, 64) || null;
|
||||
|
||||
const options = buildRegistrationOptions({
|
||||
challenge,
|
||||
userId: userHandle,
|
||||
userName: req.session.login || req.session.user,
|
||||
displayName: req.session.display_name || req.session.user,
|
||||
excludeCredentials,
|
||||
rpId: getRpIdFromHost(req.headers.host)
|
||||
});
|
||||
|
||||
// Stash the intended passkey name in challenge metadata
|
||||
if (passkeyName) {
|
||||
// Re-consume and re-store with name (challenges are stored by their value)
|
||||
// Simpler: store name in a temporary Map keyed by challenge
|
||||
options._passkeyName = passkeyName;
|
||||
}
|
||||
|
||||
return res.json({ success: true, options, passkey_name: passkeyName });
|
||||
} catch (err) {
|
||||
console.error('[PASSKEYS] register/begin error:', err);
|
||||
return res.json({ success: false, msg: err.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v2/settings/passkeys/register/finish
|
||||
* Verify attestation and store new passkey for the logged-in user.
|
||||
*/
|
||||
group.post(/\/passkeys\/register\/finish$/, lib.registeredUser, async (req, res) => {
|
||||
try {
|
||||
const body = req.post || req.body || {};
|
||||
const { challenge, clientDataJSON, attestationObject, credentialId, name } = 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 !== 'user-register' || challengeMeta.userId !== req.session.id) {
|
||||
return res.json({ success: false, msg: 'Challenge mismatch' }, 400);
|
||||
}
|
||||
|
||||
// Verify attestation
|
||||
let regResult;
|
||||
try {
|
||||
regResult = await verifyRegistration({ challenge, clientDataJSON, attestationObject, credentialId, rpId: getRpIdFromHost(req.headers.host) });
|
||||
} catch (e) {
|
||||
console.warn('[PASSKEYS] Registration verification failed:', e.message);
|
||||
return res.json({ success: false, msg: `Registration failed: ${e.message}` }, 400);
|
||||
}
|
||||
|
||||
const passkeyName = ((name || '').trim().slice(0, 64)) || 'Passkey';
|
||||
|
||||
await db`
|
||||
INSERT INTO passkey_credentials (user_id, credential_id, public_key_spki, sign_count, aaguid, name)
|
||||
VALUES (${req.session.id}, ${credentialId}, ${regResult.spki}, ${regResult.signCount}, ${regResult.aaguid || null}, ${passkeyName})
|
||||
ON CONFLICT (credential_id) DO UPDATE
|
||||
SET sign_count = ${regResult.signCount}, last_used = NOW(), name = ${passkeyName}
|
||||
`;
|
||||
|
||||
return res.json({ success: true, credential_id: credentialId, name: passkeyName });
|
||||
} catch (err) {
|
||||
console.error('[PASSKEYS] register/finish error:', err);
|
||||
return res.json({ success: false, msg: err.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v2/settings/passkeys/delete
|
||||
* Remove a passkey credential owned by the current user.
|
||||
* Uses POST + JSON body to avoid URL-encoding issues with credential IDs.
|
||||
*/
|
||||
group.post(/\/passkeys\/delete$/, lib.registeredUser, async (req, res) => {
|
||||
try {
|
||||
const body = req.post || req.body || {};
|
||||
const credentialId = body.credential_id;
|
||||
if (!credentialId) return res.json({ success: false, msg: 'Missing credential_id' }, 400);
|
||||
const result = await db`
|
||||
DELETE FROM passkey_credentials
|
||||
WHERE credential_id = ${credentialId} AND user_id = ${req.session.id}
|
||||
RETURNING id
|
||||
`;
|
||||
if (result.length === 0) return res.json({ success: false, msg: 'Passkey not found' }, 404);
|
||||
return res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('[PASSKEYS] Delete error:', err);
|
||||
return res.json({ success: false, msg: err.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/v2/settings/passkeys/:id
|
||||
* Legacy path — kept for compatibility.
|
||||
*/
|
||||
group.delete(/\/passkeys\/([^/]+)$/, lib.registeredUser, async (req, res) => {
|
||||
try {
|
||||
const credentialId = decodeURIComponent(req.url.pathname.split('/').pop());
|
||||
const result = await db`
|
||||
DELETE FROM passkey_credentials
|
||||
WHERE credential_id = ${credentialId} AND user_id = ${req.session.id}
|
||||
RETURNING id
|
||||
`;
|
||||
if (result.length === 0) return res.json({ success: false, msg: 'Passkey not found' }, 404);
|
||||
return res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('[PASSKEYS] Delete error:', err);
|
||||
return res.json({ success: false, msg: err.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v2/settings/passkeys/login/begin
|
||||
* Start a passkey login challenge for a registered user (no session required).
|
||||
*/
|
||||
group.post(/\/passkeys\/login\/begin$/, async (req, res) => {
|
||||
try {
|
||||
const challenge = generateChallenge({ type: 'user-login' });
|
||||
const options = buildAuthenticationOptions({ challenge, allowCredentials: [], rpId: getRpIdFromHost(req.headers.host) });
|
||||
return res.json({ success: true, options });
|
||||
} catch (err) {
|
||||
console.error('[PASSKEYS] login/begin error:', err);
|
||||
return res.json({ success: false, msg: err.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v2/settings/passkeys/login/finish
|
||||
* Verify assertion and create a full registered-user session.
|
||||
*/
|
||||
group.post(/\/passkeys\/login\/finish$/, async (req, res) => {
|
||||
try {
|
||||
const body = req.post || req.body || {};
|
||||
const { challenge, clientDataJSON, authenticatorData, signature, credentialId } = 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 !== 'user-login') {
|
||||
return res.json({ success: false, msg: 'Wrong challenge type' }, 400);
|
||||
}
|
||||
|
||||
// Look up credential — must belong to a registered (non-anon) user
|
||||
const credRows = await db`
|
||||
SELECT pc.user_id, pc.public_key_spki, pc.sign_count,
|
||||
u.login, u.user, u.banned, u.ban_reason, u.ban_expires, u.force_password_change
|
||||
FROM passkey_credentials pc
|
||||
JOIN "user" u ON u.id = pc.user_id
|
||||
WHERE pc.credential_id = ${credentialId}
|
||||
AND u.login IS NOT NULL
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
if (credRows.length === 0) {
|
||||
return res.json({ success: false, msg: 'No registered account found for this passkey.' }, 401);
|
||||
}
|
||||
|
||||
const row = credRows[0];
|
||||
|
||||
if (row.banned) {
|
||||
return res.json({ success: false, msg: 'Account is banned: ' + (row.ban_reason || '') }, 403);
|
||||
}
|
||||
|
||||
// Verify the assertion
|
||||
try {
|
||||
await verifyAuthentication({
|
||||
challenge,
|
||||
clientDataJSON,
|
||||
authenticatorData,
|
||||
signature,
|
||||
spki: row.public_key_spki,
|
||||
storedSignCount: row.sign_count,
|
||||
rpId: getRpIdFromHost(req.headers.host)
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[PASSKEYS] login/finish verification failed:', e.message);
|
||||
return res.json({ success: false, msg: `Authentication failed: ${e.message}` }, 401);
|
||||
}
|
||||
|
||||
// Update sign count
|
||||
await db`
|
||||
UPDATE passkey_credentials SET sign_count = sign_count + 1, last_used = NOW()
|
||||
WHERE credential_id = ${credentialId}
|
||||
`;
|
||||
|
||||
// Create a full user session (same as normal login)
|
||||
const stamp = Math.floor(Date.now() / 1000);
|
||||
const ip = (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.socket?.remoteAddress || '').split(',')[0].trim();
|
||||
const sessionToken = crypto.randomBytes(32).toString('hex');
|
||||
const csrfToken = crypto.randomBytes(32).toString('hex');
|
||||
const sessRecord = {
|
||||
user_id: row.user_id,
|
||||
session: lib.sha256(sessionToken),
|
||||
csrf_token: csrfToken,
|
||||
browser: req.headers['user-agent'] || '',
|
||||
created_at: stamp,
|
||||
last_used: stamp,
|
||||
last_action: '/passkey-login',
|
||||
kmsi: 1,
|
||||
ip
|
||||
};
|
||||
await db`INSERT INTO "user_sessions" ${db(sessRecord, 'user_id', 'session', 'csrf_token', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi', 'ip')}`;
|
||||
|
||||
res.setHeader('Set-Cookie', `session=${sessionToken}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`);
|
||||
return res.json({ success: true, user: row.user, login: row.login, force_password_change: row.force_password_change || false });
|
||||
} catch (err) {
|
||||
console.error('[PASSKEYS] login/finish error:', err);
|
||||
return res.json({ success: false, msg: err.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
return group;
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user