Add SVG assets for login devices and passkey icons
- Created a new SVG file for login devices with detailed paths and styles. - Added a new SVG file for the passkey icon with multiple vector paths and fill attributes.
This commit is contained in:
31
backend/migrations/012_passkeys.sql
Normal file
31
backend/migrations/012_passkeys.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- Discoverable WebAuthn credentials used for passwordless sign-in.
|
||||
CREATE TABLE passkey_credentials (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
credential_id VARCHAR(512) UNIQUE NOT NULL,
|
||||
public_key BYTEA NOT NULL,
|
||||
counter BIGINT NOT NULL DEFAULT 0,
|
||||
transports TEXT[] NOT NULL DEFAULT '{}',
|
||||
device_type VARCHAR(32) NOT NULL,
|
||||
backed_up BOOLEAN NOT NULL DEFAULT false,
|
||||
label VARCHAR(100),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_passkey_credentials_user_id ON passkey_credentials(user_id);
|
||||
|
||||
-- Challenges are short-lived and single-use. Keeping them in PostgreSQL lets
|
||||
-- verification work across backend restarts and multiple application instances.
|
||||
CREATE TABLE passkey_challenges (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
purpose VARCHAR(32) NOT NULL CHECK (purpose IN ('registration', 'authentication')),
|
||||
challenge VARCHAR(512) NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(purpose, challenge)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_passkey_challenges_lookup
|
||||
ON passkey_challenges(purpose, challenge, expires_at);
|
||||
5383
backend/package-lock.json
generated
Normal file
5383
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@
|
||||
"author": "Zakhar",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/server": "^14.0.2",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cheerio": "^1.2.0",
|
||||
|
||||
@@ -2,12 +2,71 @@ const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const pool = require('../config/database');
|
||||
const logger = require('../config/logger');
|
||||
const passkeyService = require('../services/passkeyService');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
const JWT_EXPIRES_IN = '24h';
|
||||
const REFRESH_TOKEN_EXPIRES_DAYS = 7;
|
||||
|
||||
class AuthController {
|
||||
async createSessionResponse(user, req) {
|
||||
const accessToken = jwt.sign(
|
||||
{ id: user.id, username: user.username, role: user.role },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: JWT_EXPIRES_IN }
|
||||
);
|
||||
|
||||
const refreshToken = jwt.sign(
|
||||
{ id: user.id, type: 'refresh' },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: `${REFRESH_TOKEN_EXPIRES_DAYS}d` }
|
||||
);
|
||||
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + REFRESH_TOKEN_EXPIRES_DAYS);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO user_sessions (user_id, refresh_token, expires_at, ip_address, user_agent)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[user.id, refreshToken, expiresAt, req.ip, req.get('user-agent')]
|
||||
);
|
||||
|
||||
await pool.query('UPDATE users SET last_login = NOW() WHERE id = $1', [user.id]);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
full_name: user.full_name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
avatar_url: user.avatar_url,
|
||||
is_active: user.is_active,
|
||||
last_login: user.last_login,
|
||||
created_at: user.created_at,
|
||||
},
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
sendPasskeyError(res, error, fallbackMessage) {
|
||||
if (error.code === 'PASSKEY_CONFIGURATION_REQUIRED') {
|
||||
return res.status(503).json({ error: 'Вход по passkey ещё не настроен для этого адреса сайта.' });
|
||||
}
|
||||
|
||||
if (error.code === 'PASSKEY_CHALLENGE_EXPIRED') {
|
||||
return res.status(400).json({ error: 'Время ожидания passkey истекло. Повторите попытку.' });
|
||||
}
|
||||
|
||||
if (error.code === 'PASSKEY_NOT_FOUND' || error.code === 'PASSKEY_VERIFICATION_FAILED' || error.code === 'PASSKEY_RESPONSE_INVALID') {
|
||||
return res.status(401).json({ error: 'Не удалось подтвердить passkey.' });
|
||||
}
|
||||
|
||||
logger.error('Passkey error:', error);
|
||||
return res.status(500).json({ error: fallbackMessage });
|
||||
}
|
||||
|
||||
// Login
|
||||
async login(req, res) {
|
||||
try {
|
||||
@@ -35,51 +94,105 @@ class AuthController {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
const accessToken = jwt.sign(
|
||||
{ id: user.id, username: user.username, role: user.role },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: JWT_EXPIRES_IN }
|
||||
);
|
||||
|
||||
const refreshToken = jwt.sign(
|
||||
{ id: user.id, type: 'refresh' },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: `${REFRESH_TOKEN_EXPIRES_DAYS}d` }
|
||||
);
|
||||
|
||||
// Save refresh token
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + REFRESH_TOKEN_EXPIRES_DAYS);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO user_sessions (user_id, refresh_token, expires_at, ip_address, user_agent)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[user.id, refreshToken, expiresAt, req.ip, req.get('user-agent')]
|
||||
);
|
||||
|
||||
// Update last login
|
||||
await pool.query(
|
||||
'UPDATE users SET last_login = NOW() WHERE id = $1',
|
||||
[user.id]
|
||||
);
|
||||
|
||||
// Remove password hash from response
|
||||
delete user.password_hash;
|
||||
|
||||
logger.info(`User logged in: ${username}`);
|
||||
|
||||
res.json({
|
||||
user,
|
||||
accessToken,
|
||||
refreshToken
|
||||
});
|
||||
res.json(await this.createSessionResponse(user, req));
|
||||
} catch (error) {
|
||||
logger.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
async generatePasskeyRegistrationOptions(req, res) {
|
||||
try {
|
||||
const userResult = await pool.query(
|
||||
`SELECT id, username, full_name
|
||||
FROM users
|
||||
WHERE id = $1 AND is_active = true`,
|
||||
[req.user.id]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Пользователь не найден.' });
|
||||
}
|
||||
|
||||
res.json(await passkeyService.generateRegistrationOptionsForUser(req, userResult.rows[0]));
|
||||
} catch (error) {
|
||||
this.sendPasskeyError(res, error, 'Не удалось начать регистрацию passkey.');
|
||||
}
|
||||
}
|
||||
|
||||
async verifyPasskeyRegistration(req, res) {
|
||||
try {
|
||||
const userResult = await pool.query(
|
||||
`SELECT id, username, full_name
|
||||
FROM users
|
||||
WHERE id = $1 AND is_active = true`,
|
||||
[req.user.id]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Пользователь не найден.' });
|
||||
}
|
||||
|
||||
const passkey = await passkeyService.verifyRegistrationForUser(
|
||||
req,
|
||||
userResult.rows[0],
|
||||
req.body.response,
|
||||
req.body.label,
|
||||
);
|
||||
logger.info(`Passkey registered for user: ${userResult.rows[0].username}`);
|
||||
res.status(201).json({ passkey });
|
||||
} catch (error) {
|
||||
this.sendPasskeyError(res, error, 'Не удалось сохранить passkey.');
|
||||
}
|
||||
}
|
||||
|
||||
async generatePasskeyAuthenticationOptions(req, res) {
|
||||
try {
|
||||
res.json(await passkeyService.generateAuthenticationOptionsForRequest(req));
|
||||
} catch (error) {
|
||||
this.sendPasskeyError(res, error, 'Не удалось начать вход по passkey.');
|
||||
}
|
||||
}
|
||||
|
||||
async verifyPasskeyAuthentication(req, res) {
|
||||
try {
|
||||
const user = await passkeyService.verifyAuthenticationForRequest(req, req.body.response);
|
||||
logger.info(`User logged in with passkey: ${user.username}`);
|
||||
res.json(await this.createSessionResponse(user, req));
|
||||
} catch (error) {
|
||||
this.sendPasskeyError(res, error, 'Не удалось выполнить вход по passkey.');
|
||||
}
|
||||
}
|
||||
|
||||
async listPasskeys(req, res) {
|
||||
try {
|
||||
res.json({ passkeys: await passkeyService.listUserPasskeys(req.user.id) });
|
||||
} catch (error) {
|
||||
logger.error('List passkeys error:', error);
|
||||
res.status(500).json({ error: 'Не удалось получить список passkey.' });
|
||||
}
|
||||
}
|
||||
|
||||
async removePasskey(req, res) {
|
||||
try {
|
||||
const passkeyId = Number(req.params.passkeyId);
|
||||
if (!Number.isSafeInteger(passkeyId) || passkeyId < 1) {
|
||||
return res.status(400).json({ error: 'Некорректный идентификатор passkey.' });
|
||||
}
|
||||
|
||||
const removed = await passkeyService.removeUserPasskey(req.user.id, passkeyId);
|
||||
if (!removed) {
|
||||
return res.status(404).json({ error: 'Passkey не найден.' });
|
||||
}
|
||||
|
||||
res.json({ message: 'Passkey удалён.' });
|
||||
} catch (error) {
|
||||
logger.error('Remove passkey error:', error);
|
||||
res.status(500).json({ error: 'Не удалось удалить passkey.' });
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh token
|
||||
async refresh(req, res) {
|
||||
try {
|
||||
@@ -229,4 +342,4 @@ class AuthController {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AuthController();
|
||||
module.exports = new AuthController();
|
||||
|
||||
@@ -7,9 +7,15 @@ const { authenticateToken } = require('../middleware/auth');
|
||||
router.post('/login', authController.login.bind(authController));
|
||||
router.post('/refresh', authController.refresh.bind(authController));
|
||||
router.post('/logout', authController.logout.bind(authController));
|
||||
router.post('/passkeys/authentication/options', authController.generatePasskeyAuthenticationOptions.bind(authController));
|
||||
router.post('/passkeys/authentication/verify', authController.verifyPasskeyAuthentication.bind(authController));
|
||||
|
||||
// Protected routes
|
||||
router.get('/me', authenticateToken, authController.me.bind(authController));
|
||||
router.post('/change-password', authenticateToken, authController.changePassword.bind(authController));
|
||||
router.get('/passkeys', authenticateToken, authController.listPasskeys.bind(authController));
|
||||
router.post('/passkeys/registration/options', authenticateToken, authController.generatePasskeyRegistrationOptions.bind(authController));
|
||||
router.post('/passkeys/registration/verify', authenticateToken, authController.verifyPasskeyRegistration.bind(authController));
|
||||
router.delete('/passkeys/:passkeyId', authenticateToken, authController.removePasskey.bind(authController));
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
325
backend/src/services/passkeyService.js
Normal file
325
backend/src/services/passkeyService.js
Normal file
@@ -0,0 +1,325 @@
|
||||
const {
|
||||
generateRegistrationOptions,
|
||||
generateAuthenticationOptions,
|
||||
verifyRegistrationResponse,
|
||||
verifyAuthenticationResponse,
|
||||
} = require('@simplewebauthn/server');
|
||||
|
||||
const pool = require('../config/database');
|
||||
|
||||
const CHALLENGE_TTL_MINUTES = 5;
|
||||
const PASSKEY_RP_NAME = process.env.WEBAUTHN_RP_NAME || 'RideBus DE';
|
||||
|
||||
function decodeClientDataChallenge(response) {
|
||||
const encodedClientData = response?.response?.clientDataJSON;
|
||||
|
||||
if (!encodedClientData || typeof encodedClientData !== 'string') {
|
||||
throw new Error('Passkey response does not contain client data');
|
||||
}
|
||||
|
||||
let clientData;
|
||||
try {
|
||||
clientData = JSON.parse(Buffer.from(encodedClientData, 'base64url').toString('utf8'));
|
||||
} catch {
|
||||
throw new Error('Passkey response contains invalid client data');
|
||||
}
|
||||
|
||||
if (!clientData.challenge || typeof clientData.challenge !== 'string') {
|
||||
throw new Error('Passkey response does not contain a challenge');
|
||||
}
|
||||
|
||||
return clientData.challenge;
|
||||
}
|
||||
|
||||
function parseConfiguredOrigins() {
|
||||
return (process.env.WEBAUTHN_ORIGIN || process.env.WEBAUTHN_ORIGINS || '')
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function getPasskeyConfig(req) {
|
||||
const configuredOrigins = parseConfiguredOrigins();
|
||||
const configuredRpId = process.env.WEBAUTHN_RP_ID?.trim();
|
||||
|
||||
if (configuredRpId && configuredOrigins.length > 0) {
|
||||
return {
|
||||
rpID: configuredRpId,
|
||||
origins: configuredOrigins,
|
||||
};
|
||||
}
|
||||
|
||||
// A safe local-development fallback. Production must name the public origin
|
||||
// explicitly: the browser will otherwise reject passkeys on a real domain.
|
||||
const requestOrigin = req.get('origin');
|
||||
const localOrigin = requestOrigin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(requestOrigin);
|
||||
|
||||
if (process.env.NODE_ENV !== 'production' && localOrigin) {
|
||||
return {
|
||||
rpID: new URL(requestOrigin).hostname,
|
||||
origins: [requestOrigin],
|
||||
};
|
||||
}
|
||||
|
||||
const error = new Error('Passkeys require WEBAUTHN_RP_ID and WEBAUTHN_ORIGIN to be configured');
|
||||
error.code = 'PASSKEY_CONFIGURATION_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
async function storeChallenge({ userId = null, purpose, challenge }) {
|
||||
const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MINUTES * 60 * 1000);
|
||||
await pool.query(
|
||||
`INSERT INTO passkey_challenges (user_id, purpose, challenge, expires_at)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[userId, purpose, challenge, expiresAt],
|
||||
);
|
||||
|
||||
// Keep this table bounded without making authentication depend on a scheduled job.
|
||||
await pool.query('DELETE FROM passkey_challenges WHERE expires_at <= NOW()');
|
||||
}
|
||||
|
||||
async function claimChallenge({ purpose, challenge, userId = null }) {
|
||||
const params = [purpose, challenge];
|
||||
let userFilter = '';
|
||||
|
||||
if (userId !== null) {
|
||||
params.push(userId);
|
||||
userFilter = ` AND user_id = $${params.length}`;
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`DELETE FROM passkey_challenges
|
||||
WHERE id = (
|
||||
SELECT id
|
||||
FROM passkey_challenges
|
||||
WHERE purpose = $1 AND challenge = $2 AND expires_at > NOW()${userFilter}
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING id, user_id, challenge`,
|
||||
params,
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
async function getUserCredentials(userId) {
|
||||
const result = await pool.query(
|
||||
`SELECT credential_id, transports
|
||||
FROM passkey_credentials
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at ASC`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async function generateRegistrationOptionsForUser(req, user) {
|
||||
const config = getPasskeyConfig(req);
|
||||
const credentials = await getUserCredentials(user.id);
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: PASSKEY_RP_NAME,
|
||||
rpID: config.rpID,
|
||||
userID: new TextEncoder().encode(String(user.id)),
|
||||
userName: user.username,
|
||||
userDisplayName: user.full_name || user.username,
|
||||
attestationType: 'none',
|
||||
excludeCredentials: credentials.map((credential) => ({
|
||||
id: credential.credential_id,
|
||||
transports: credential.transports || undefined,
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
residentKey: 'required',
|
||||
userVerification: 'required',
|
||||
},
|
||||
});
|
||||
|
||||
await storeChallenge({
|
||||
userId: user.id,
|
||||
purpose: 'registration',
|
||||
challenge: options.challenge,
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
async function verifyRegistrationForUser(req, user, response, label) {
|
||||
const config = getPasskeyConfig(req);
|
||||
const challenge = decodeClientDataChallenge(response);
|
||||
const pendingChallenge = await claimChallenge({
|
||||
purpose: 'registration',
|
||||
challenge,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (!pendingChallenge) {
|
||||
const error = new Error('Passkey registration has expired. Please try again.');
|
||||
error.code = 'PASSKEY_CHALLENGE_EXPIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge: pendingChallenge.challenge,
|
||||
expectedOrigin: config.origins,
|
||||
expectedRPID: config.rpID,
|
||||
requireUserVerification: true,
|
||||
});
|
||||
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
const error = new Error('Passkey could not be verified');
|
||||
error.code = 'PASSKEY_VERIFICATION_FAILED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
|
||||
const normalizedLabel = typeof label === 'string'
|
||||
? label.trim().slice(0, 100) || null
|
||||
: null;
|
||||
await pool.query(
|
||||
`INSERT INTO passkey_credentials
|
||||
(user_id, credential_id, public_key, counter, transports, device_type, backed_up, label)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[
|
||||
user.id,
|
||||
credential.id,
|
||||
Buffer.from(credential.publicKey),
|
||||
credential.counter,
|
||||
response.response.transports || [],
|
||||
credentialDeviceType,
|
||||
credentialBackedUp,
|
||||
normalizedLabel,
|
||||
],
|
||||
);
|
||||
return {
|
||||
id: credential.id,
|
||||
deviceType: credentialDeviceType,
|
||||
backedUp: credentialBackedUp,
|
||||
};
|
||||
}
|
||||
|
||||
async function generateAuthenticationOptionsForRequest(req) {
|
||||
const config = getPasskeyConfig(req);
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: config.rpID,
|
||||
userVerification: 'required',
|
||||
});
|
||||
|
||||
await storeChallenge({
|
||||
purpose: 'authentication',
|
||||
challenge: options.challenge,
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
async function verifyAuthenticationForRequest(req, response) {
|
||||
const config = getPasskeyConfig(req);
|
||||
const challenge = decodeClientDataChallenge(response);
|
||||
const credentialId = response?.id;
|
||||
|
||||
if (!credentialId || typeof credentialId !== 'string') {
|
||||
const error = new Error('Passkey response does not contain a credential');
|
||||
error.code = 'PASSKEY_RESPONSE_INVALID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const pendingChallenge = await claimChallenge({
|
||||
purpose: 'authentication',
|
||||
challenge,
|
||||
});
|
||||
|
||||
if (!pendingChallenge) {
|
||||
const error = new Error('Passkey authentication has expired. Please try again.');
|
||||
error.code = 'PASSKEY_CHALLENGE_EXPIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const credentialResult = await pool.query(
|
||||
`SELECT pc.*, u.id AS user_id, u.username, u.full_name, u.email, u.role, u.avatar_url,
|
||||
u.is_active, u.last_login, u.created_at AS user_created_at
|
||||
FROM passkey_credentials pc
|
||||
JOIN users u ON u.id = pc.user_id
|
||||
WHERE pc.credential_id = $1 AND u.is_active = true
|
||||
LIMIT 1`,
|
||||
[credentialId],
|
||||
);
|
||||
const storedCredential = credentialResult.rows[0];
|
||||
|
||||
if (!storedCredential) {
|
||||
const error = new Error('Passkey is not registered for an active account');
|
||||
error.code = 'PASSKEY_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: pendingChallenge.challenge,
|
||||
expectedOrigin: config.origins,
|
||||
expectedRPID: config.rpID,
|
||||
requireUserVerification: true,
|
||||
credential: {
|
||||
id: storedCredential.credential_id,
|
||||
publicKey: new Uint8Array(storedCredential.public_key),
|
||||
counter: Number(storedCredential.counter),
|
||||
transports: storedCredential.transports || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (!verification.verified) {
|
||||
const error = new Error('Passkey could not be verified');
|
||||
error.code = 'PASSKEY_VERIFICATION_FAILED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`UPDATE passkey_credentials
|
||||
SET counter = $1, last_used_at = NOW(), backed_up = $2
|
||||
WHERE id = $3`,
|
||||
[
|
||||
verification.authenticationInfo.newCounter,
|
||||
verification.authenticationInfo.credentialBackedUp,
|
||||
storedCredential.id,
|
||||
],
|
||||
);
|
||||
return {
|
||||
id: storedCredential.user_id,
|
||||
username: storedCredential.username,
|
||||
full_name: storedCredential.full_name,
|
||||
email: storedCredential.email,
|
||||
role: storedCredential.role,
|
||||
avatar_url: storedCredential.avatar_url,
|
||||
is_active: storedCredential.is_active,
|
||||
last_login: storedCredential.last_login,
|
||||
created_at: storedCredential.user_created_at,
|
||||
};
|
||||
}
|
||||
|
||||
async function listUserPasskeys(userId) {
|
||||
const result = await pool.query(
|
||||
`SELECT id, label, device_type, backed_up, created_at, last_used_at
|
||||
FROM passkey_credentials
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
[userId],
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async function removeUserPasskey(userId, passkeyId) {
|
||||
const result = await pool.query(
|
||||
'DELETE FROM passkey_credentials WHERE id = $1 AND user_id = $2 RETURNING id',
|
||||
[passkeyId, userId],
|
||||
);
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateRegistrationOptionsForUser,
|
||||
verifyRegistrationForUser,
|
||||
generateAuthenticationOptionsForRequest,
|
||||
verifyAuthenticationForRequest,
|
||||
listUserPasskeys,
|
||||
removeUserPasskey,
|
||||
};
|
||||
Reference in New Issue
Block a user