feat(card1): версии тестов API, черновик, HR-login, import, UI

- V.1–V.3: saveTestDraft, fork при попытках; миграция 003 staff_id
- V.4–V.6: REST /api/tests, activate, PATCH, start attempt
- A: HR_DATABASE_URL + Werkzeug/bcrypt, JWT staffId, HR_AUTH
- D.1: multipart /api/tests/import/document
- Frontend: login, список тестов, экран версий/черновика/попытки
- ТЗ: V.10 назначения vs активная версия; журнал приёма

Made-with: Cursor
This commit is contained in:
Константин Лебединский
2026-04-24 20:30:09 +05:00
parent 7fa6f98ee1
commit 5631d85238
37 changed files with 9687 additions and 59 deletions
+94
View File
@@ -0,0 +1,94 @@
/**
* Authentication Utilities
* Password hashing and JWT token management
*/
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import dotenv from 'dotenv';
import { checkWerkzeugPassword } from './werkzeugPassword.js';
import { HR_MANAGED_PASSWORD_PLACEHOLDER } from '../config/authConstants.js';
dotenv.config();
const JWT_SECRET = process.env.JWT_SECRET;
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
// Salt rounds for bcrypt
const SALT_ROUNDS = 10;
/**
* Hash a password using bcrypt
* @param {string} password - Plain text password
* @returns {Promise<string>} Hashed password
*/
export async function hashPassword(password) {
return bcrypt.hash(password, SALT_ROUNDS);
}
/**
* Compare a plain text password with a hashed password
* @param {string} password - Plain text password
* @param {string} hash - Hashed password
* @returns {Promise<boolean>} True if passwords match
*/
export async function comparePassword(password, hash) {
if (!hash) {
return false;
}
if (hash === HR_MANAGED_PASSWORD_PLACEHOLDER) {
return false;
}
if (hash.startsWith('scrypt:') || hash.startsWith('pbkdf2:')) {
return checkWerkzeugPassword(hash, password);
}
if (hash.startsWith('$2')) {
return bcrypt.compare(password, hash);
}
return checkWerkzeugPassword(hash, password);
}
/**
* @param {string} userId - clinic_tests.users.id
* @param {string} role
* @param {string|null|undefined} departmentId
* @param {{ staffId?: number } | null} [meta]
* @returns {string}
*/
export function generateToken(userId, role, departmentId = null, meta = null) {
const payload = {
userId,
role,
};
if (departmentId !== null && departmentId !== undefined) {
payload.departmentId = departmentId;
}
if (meta?.staffId != null) {
payload.staffId = meta.staffId;
}
return jwt.sign(payload, JWT_SECRET, {
expiresIn: JWT_EXPIRES_IN,
});
}
/**
* Verify and decode a JWT token
* @param {string} token - JWT token
* @returns {Object|null} Decoded token payload or null if invalid
*/
export function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET);
} catch (error) {
return null;
}
}
export default {
hashPassword,
comparePassword,
generateToken,
verifyToken,
};
+21
View File
@@ -0,0 +1,21 @@
/**
* Сопоставление role из hr_bot_test.users (varchar) с ролью в модуле тестов.
* A.5 MVP — уточняется при подключении staff_role_assignments.
* @param {string|null|undefined} hrRole
* @returns {'hr' | 'manager' | 'employee'}
*/
export function mapHrRoleToApp(hrRole) {
const r = String(hrRole || '')
.toLowerCase()
.trim();
if (!r) {
return 'employee';
}
if (r === 'admin' || r.includes('hr') || r.includes('дире')) {
return 'hr';
}
if (r.includes('manager') || r.includes('рук') || r.includes('завед')) {
return 'manager';
}
return 'employee';
}
+74
View File
@@ -0,0 +1,74 @@
/**
* Проверка хеша в формате Werkzeug 3 (scrypt:… / pbkdf2:…).
* @see https://github.com/pallets/werkzeug/blob/main/src/werkzeug/security.py
*/
import crypto from 'crypto';
/**
* @param {string} pwhash
* @param {string} password
* @returns {boolean}
*/
function hashInternal(method, salt, password) {
const methodParts = method.split(':');
const kind = methodParts[0];
const saltBytes = Buffer.from(salt, 'utf8');
const passwordBytes = Buffer.from(password, 'utf8');
if (kind === 'scrypt') {
const n = methodParts[1] ? parseInt(methodParts[1], 10) : 2 ** 15;
const r = methodParts[2] ? parseInt(methodParts[2], 10) : 8;
const p = methodParts[3] ? parseInt(methodParts[3], 10) : 1;
const maxmem = 132 * n * r * p;
return crypto
.scryptSync(passwordBytes, saltBytes, 64, { N: n, r, p, maxmem })
.toString('hex');
}
if (kind === 'pbkdf2') {
const hashName = methodParts[1] || 'sha256';
const iterStr = methodParts[2];
if (!iterStr) {
throw new Error('pbkdf2: missing iterations');
}
const iterations = parseInt(iterStr, 10);
return crypto
.pbkdf2Sync(passwordBytes, saltBytes, iterations, 32, hashName)
.toString('hex');
}
throw new Error(`Invalid hash method: ${kind}`);
}
/**
* @param {string} pwhash
* @param {string} password
* @returns {boolean}
*/
export function checkWerkzeugPassword(pwhash, password) {
if (!pwhash || pwhash.length < 3) {
return false;
}
const parts = pwhash.split('$');
if (parts.length < 3) {
return false;
}
const hashval = parts.pop();
const salt = parts.pop();
const method = parts.join('$');
if (!method || !salt || !hashval) {
return false;
}
let computed;
try {
computed = hashInternal(method, salt, password);
} catch {
return false;
}
const a = Buffer.from(computed, 'hex');
const b = Buffer.from(hashval, 'hex');
if (a.length !== b.length) {
return false;
}
return crypto.timingSafeEqual(a, b);
}
@@ -0,0 +1,31 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import crypto from 'crypto';
import { checkWerkzeugPassword } from './werkzeugPassword.js';
test('pbkdf2:sha256 self-consistency', () => {
const salt = 'AbCdEfGhIjKlMnOp';
const iterations = 1000;
const password = 'secret';
const hashval = crypto
.pbkdf2Sync(password, salt, iterations, 32, 'sha256')
.toString('hex');
const pwhash = `pbkdf2:sha256:${iterations}$${salt}$${hashval}`;
assert.equal(checkWerkzeugPassword(pwhash, 'secret'), true);
assert.equal(checkWerkzeugPassword(pwhash, 'wrong'), false);
});
test('scrypt self-consistency', () => {
const salt = 'AbCdEfGhIjKlMnOp';
const n = 32768;
const r = 8;
const p = 1;
const maxmem = 132 * n * r * p;
const method = `scrypt:${n}:${r}:${p}`;
const password = 'x';
const h = crypto
.scryptSync(password, salt, 64, { N: n, r, p, maxmem })
.toString('hex');
const pwhash = `${method}$${salt}$${h}`;
assert.equal(checkWerkzeugPassword(pwhash, 'x'), true);
});