e71bfa35dc
Co-authored-by: Cursor <cursoragent@cursor.com>
243 lines
6.5 KiB
JavaScript
243 lines
6.5 KiB
JavaScript
const crypto = require('crypto');
|
|
const {
|
|
generateRegistrationOptions,
|
|
verifyRegistrationResponse,
|
|
generateAuthenticationOptions,
|
|
verifyAuthenticationResponse,
|
|
} = require('@simplewebauthn/server');
|
|
const { isoBase64URL } = require('@simplewebauthn/server/helpers');
|
|
const { query } = require('../db');
|
|
|
|
function getRpId() {
|
|
if (process.env.WEBAUTHN_RP_ID) {
|
|
return process.env.WEBAUTHN_RP_ID.trim();
|
|
}
|
|
const site = process.env.SITE_URL || 'http://localhost:3000';
|
|
try {
|
|
return new URL(site).hostname;
|
|
} catch {
|
|
return 'localhost';
|
|
}
|
|
}
|
|
|
|
function getRpName() {
|
|
return process.env.WEBAUTHN_RP_NAME || 'Shop';
|
|
}
|
|
|
|
function getOrigins() {
|
|
const list = [];
|
|
if (process.env.SITE_URL) list.push(process.env.SITE_URL.replace(/\/$/, ''));
|
|
if (process.env.WEBAUTHN_ORIGIN) {
|
|
process.env.WEBAUTHN_ORIGIN.split(',').forEach((o) => {
|
|
const t = o.trim().replace(/\/$/, '');
|
|
if (t) list.push(t);
|
|
});
|
|
}
|
|
if (!list.length) list.push('http://localhost:3000');
|
|
const expanded = [...list];
|
|
for (const o of list) {
|
|
if (o.includes('localhost')) {
|
|
expanded.push(o.replace('localhost', '127.0.0.1'));
|
|
}
|
|
}
|
|
return [...new Set(expanded)];
|
|
}
|
|
|
|
function getOriginFromRequest(req) {
|
|
const proto = req.get('x-forwarded-proto') || req.protocol;
|
|
const host = req.get('x-forwarded-host') || req.get('host');
|
|
return `${proto}://${host}`.replace(/\/$/, '');
|
|
}
|
|
|
|
function assertOrigin(req) {
|
|
const origin = getOriginFromRequest(req);
|
|
const allowed = getOrigins();
|
|
if (!allowed.includes(origin)) {
|
|
const err = new Error('Недопустимый origin для WebAuthn');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
return origin;
|
|
}
|
|
|
|
function userIdToBuffer(userId) {
|
|
const buf = Buffer.alloc(8);
|
|
buf.writeBigUInt64BE(BigInt(userId), 0);
|
|
return new Uint8Array(buf);
|
|
}
|
|
|
|
async function getCredentialsForUser(userId) {
|
|
const { rows } = await query(
|
|
`SELECT id, credential_id, public_key, counter, device_type, backed_up, transports, label, created_at
|
|
FROM webauthn_credentials WHERE user_id = $1 ORDER BY created_at`,
|
|
[userId]
|
|
);
|
|
return rows;
|
|
}
|
|
|
|
function rowToAuthenticator(row) {
|
|
return {
|
|
id: row.credential_id,
|
|
publicKey: row.public_key,
|
|
counter: Number(row.counter),
|
|
transports: row.transports ? row.transports.split(',') : undefined,
|
|
};
|
|
}
|
|
|
|
async function generateRegisterOptions(user, excludeIds = []) {
|
|
const credentials = await getCredentialsForUser(user.id);
|
|
return generateRegistrationOptions({
|
|
rpName: getRpName(),
|
|
rpID: getRpId(),
|
|
userName: user.email,
|
|
userDisplayName: user.name,
|
|
userID: userIdToBuffer(user.id),
|
|
attestationType: 'none',
|
|
excludeCredentials: credentials.map((c) => ({
|
|
id: c.credential_id,
|
|
transports: c.transports ? c.transports.split(',') : undefined,
|
|
})),
|
|
authenticatorSelection: {
|
|
residentKey: 'preferred',
|
|
userVerification: 'preferred',
|
|
},
|
|
});
|
|
}
|
|
|
|
async function verifyRegister(user, response, expectedChallenge, expectedOrigin) {
|
|
const verification = await verifyRegistrationResponse({
|
|
response,
|
|
expectedChallenge,
|
|
expectedOrigin,
|
|
expectedRPID: getRpId(),
|
|
requireUserVerification: false,
|
|
});
|
|
|
|
if (!verification.verified || !verification.registrationInfo) {
|
|
return { verified: false };
|
|
}
|
|
|
|
const { credential, credentialDeviceType, credentialBackedUp } =
|
|
verification.registrationInfo;
|
|
|
|
const credentialId =
|
|
typeof credential.id === 'string'
|
|
? credential.id
|
|
: isoBase64URL.fromBuffer(credential.id);
|
|
const label =
|
|
credentialDeviceType === 'singleDevice' ? 'Это устройство' : 'Passkey';
|
|
|
|
await query(
|
|
`INSERT INTO webauthn_credentials
|
|
(user_id, credential_id, public_key, counter, device_type, backed_up, transports, label)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
[
|
|
user.id,
|
|
credentialId,
|
|
Buffer.from(credential.publicKey),
|
|
credential.counter,
|
|
credentialDeviceType,
|
|
credentialBackedUp,
|
|
credential.transports?.join(',') || null,
|
|
label,
|
|
]
|
|
);
|
|
|
|
await query('UPDATE users SET passkey_enabled = true WHERE id = $1', [user.id]);
|
|
|
|
return { verified: true };
|
|
}
|
|
|
|
async function generateLoginOptions(email) {
|
|
const { rows } = await query(
|
|
`SELECT id, email, name, passkey_enabled FROM users WHERE email = $1`,
|
|
[email.trim().toLowerCase()]
|
|
);
|
|
const user = rows[0];
|
|
if (!user || !user.passkey_enabled) {
|
|
return { user: null, options: null };
|
|
}
|
|
|
|
const credentials = await getCredentialsForUser(user.id);
|
|
if (!credentials.length) {
|
|
return { user: null, options: null };
|
|
}
|
|
|
|
const options = await generateAuthenticationOptions({
|
|
rpID: getRpId(),
|
|
allowCredentials: credentials.map((c) => ({
|
|
id: c.credential_id,
|
|
transports: c.transports ? c.transports.split(',') : undefined,
|
|
})),
|
|
userVerification: 'preferred',
|
|
});
|
|
|
|
return { user, options };
|
|
}
|
|
|
|
async function verifyLogin(user, response, expectedChallenge, expectedOrigin) {
|
|
const credentialId = response.id;
|
|
const { rows } = await query(
|
|
`SELECT * FROM webauthn_credentials WHERE user_id = $1 AND credential_id = $2`,
|
|
[user.id, credentialId]
|
|
);
|
|
const row = rows[0];
|
|
if (!row) {
|
|
return { verified: false };
|
|
}
|
|
|
|
const verification = await verifyAuthenticationResponse({
|
|
response,
|
|
expectedChallenge,
|
|
expectedOrigin,
|
|
expectedRPID: getRpId(),
|
|
credential: rowToAuthenticator(row),
|
|
requireUserVerification: false,
|
|
});
|
|
|
|
if (!verification.verified) {
|
|
return { verified: false };
|
|
}
|
|
|
|
const { newCounter } = verification.authenticationInfo;
|
|
await query('UPDATE webauthn_credentials SET counter = $1 WHERE id = $2', [
|
|
newCounter,
|
|
row.id,
|
|
]);
|
|
|
|
return { verified: true };
|
|
}
|
|
|
|
async function disablePasskeys(userId) {
|
|
await query('DELETE FROM webauthn_credentials WHERE user_id = $1', [userId]);
|
|
await query('UPDATE users SET passkey_enabled = false WHERE id = $1', [userId]);
|
|
}
|
|
|
|
async function deleteCredential(userId, credentialDbId) {
|
|
const { rowCount } = await query(
|
|
'DELETE FROM webauthn_credentials WHERE id = $1 AND user_id = $2',
|
|
[credentialDbId, userId]
|
|
);
|
|
const remaining = await query(
|
|
'SELECT COUNT(*)::int AS n FROM webauthn_credentials WHERE user_id = $1',
|
|
[userId]
|
|
);
|
|
if (remaining.rows[0].n === 0) {
|
|
await query('UPDATE users SET passkey_enabled = false WHERE id = $1', [userId]);
|
|
}
|
|
return rowCount > 0;
|
|
}
|
|
|
|
module.exports = {
|
|
getRpId,
|
|
getOrigins,
|
|
assertOrigin,
|
|
getCredentialsForUser,
|
|
generateRegisterOptions,
|
|
verifyRegister,
|
|
generateLoginOptions,
|
|
verifyLogin,
|
|
disablePasskeys,
|
|
deleteCredential,
|
|
};
|