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:
2026-09-17 20:31:10 +03:00
parent acec6b4fc0
commit f218f0123a
22 changed files with 9839 additions and 169 deletions

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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();

View File

@@ -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;

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