feat: passkey в профиле и входе, кнопка админки в кабинете
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user