alotta good shit

This commit is contained in:
2026-09-19 06:20:37 +02:00
parent 74f7884525
commit 1477d56658
45 changed files with 4363 additions and 2069 deletions
+274
View File
@@ -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;
});