Files
shop10/src/services/webauthn.js
T

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,
};