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:
@@ -0,0 +1,45 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import dotenv from 'dotenv';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import testsRoutes from './routes/tests.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export function createApp() {
|
||||
const app = express();
|
||||
const corsOrigins =
|
||||
process.env.NODE_ENV === 'production'
|
||||
? process.env.FRONTEND_URL
|
||||
? [process.env.FRONTEND_URL]
|
||||
: []
|
||||
: ['http://localhost:5173', 'http://localhost:3000'];
|
||||
app.use(
|
||||
cors({
|
||||
origin: corsOrigins.length ? corsOrigins : true,
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/tests', testsRoutes);
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
message: 'Server is running',
|
||||
});
|
||||
});
|
||||
app.use((err, req, res, _next) => {
|
||||
console.error('Error:', err);
|
||||
res.status(err.status || 500).json({
|
||||
error: err.message || 'Internal Server Error',
|
||||
});
|
||||
});
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
});
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Пароль-заглушка: вход только через HR, локальный compare не пройдёт. */
|
||||
export const HR_MANAGED_PASSWORD_PLACEHOLDER = '$HR$MANAGED$NO_LOCAL$';
|
||||
|
||||
/** HR login enabled (1 = Werkzeug + upsert user по staff_id) */
|
||||
export function isHrAuthEnabled() {
|
||||
return process.env.HR_AUTH === '1' || process.env.HR_AUTH === 'true';
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Read-only (по соглашению) пул к hr_bot_test для логина и справки по сотруднику.
|
||||
*/
|
||||
import pg from 'pg';
|
||||
import { getHrPoolConfig } from './poolConfig.js';
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
const cfg = getHrPoolConfig();
|
||||
const pool = cfg ? new Pool(cfg) : null;
|
||||
|
||||
export function getHrPool() {
|
||||
return pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {unknown[]} [params]
|
||||
*/
|
||||
export async function queryHr(text, params) {
|
||||
if (!pool) {
|
||||
throw new Error('HR database not configured (set HR_DATABASE_URL)');
|
||||
}
|
||||
return pool.query(text, params);
|
||||
}
|
||||
|
||||
export default { getHrPool, queryHr };
|
||||
@@ -0,0 +1,130 @@
|
||||
-- Initial database schema for clinic tests application
|
||||
-- Version: 1.0
|
||||
|
||||
-- Enable UUID extension
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Departments table
|
||||
CREATE TABLE IF NOT EXISTS departments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- User roles enum
|
||||
CREATE TYPE user_role AS ENUM ('hr', 'manager', 'employee');
|
||||
|
||||
-- Users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
login VARCHAR(100) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
full_name VARCHAR(255) NOT NULL,
|
||||
role user_role NOT NULL DEFAULT 'employee',
|
||||
department_id UUID REFERENCES departments(id),
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create index for login lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_users_login ON users(login);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_department ON users(department_id);
|
||||
|
||||
-- Tests table
|
||||
CREATE TABLE IF NOT EXISTS tests (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
passing_threshold INTEGER DEFAULT 70,
|
||||
time_limit INTEGER,
|
||||
allow_back BOOLEAN DEFAULT true,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
is_versioned BOOLEAN DEFAULT false,
|
||||
created_by UUID REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Test versions table
|
||||
CREATE TABLE IF NOT EXISTS test_versions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
test_id UUID REFERENCES tests(id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
is_active BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(test_id, version)
|
||||
);
|
||||
|
||||
-- Questions table
|
||||
CREATE TABLE IF NOT EXISTS questions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
test_version_id UUID REFERENCES test_versions(id) ON DELETE CASCADE,
|
||||
text TEXT NOT NULL,
|
||||
question_order INTEGER NOT NULL,
|
||||
has_multiple_answers BOOLEAN DEFAULT false
|
||||
);
|
||||
|
||||
-- Answer options table
|
||||
CREATE TABLE IF NOT EXISTS answer_options (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
question_id UUID REFERENCES questions(id) ON DELETE CASCADE,
|
||||
text TEXT NOT NULL,
|
||||
is_correct BOOLEAN DEFAULT false,
|
||||
option_order INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Test assignments table
|
||||
CREATE TABLE IF NOT EXISTS test_assignments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
test_version_id UUID REFERENCES test_versions(id) ON DELETE CASCADE,
|
||||
assigned_by UUID REFERENCES users(id),
|
||||
deadline DATE,
|
||||
max_attempts INTEGER DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Assignment targets table
|
||||
CREATE TYPE target_type AS ENUM ('department', 'user');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS test_assignment_targets (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
assignment_id UUID REFERENCES test_assignments(id) ON DELETE CASCADE,
|
||||
target_type target_type NOT NULL,
|
||||
target_id UUID NOT NULL
|
||||
);
|
||||
|
||||
-- Test attempts table
|
||||
CREATE TYPE attempt_status AS ENUM ('in_progress', 'completed', 'expired');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS test_attempts (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
test_version_id UUID REFERENCES test_versions(id) ON DELETE CASCADE,
|
||||
user_id UUID REFERENCES users(id),
|
||||
attempt_number INTEGER NOT NULL DEFAULT 1,
|
||||
status attempt_status DEFAULT 'in_progress',
|
||||
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
correct_count INTEGER DEFAULT 0,
|
||||
total_questions INTEGER DEFAULT 0,
|
||||
passed BOOLEAN DEFAULT false,
|
||||
UNIQUE(test_version_id, user_id, attempt_number)
|
||||
);
|
||||
|
||||
-- User answers table
|
||||
CREATE TABLE IF NOT EXISTS user_answers (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
attempt_id UUID REFERENCES test_attempts(id) ON DELETE CASCADE,
|
||||
question_id UUID REFERENCES questions(id) ON DELETE CASCADE,
|
||||
selected_options UUID[] DEFAULT '{}'
|
||||
);
|
||||
|
||||
-- Settings table
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
|
||||
-- Insert default admin user (password: admin123)
|
||||
-- This will be done via application code to properly hash the password
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Связь пользователя клиник-теста с сотрудником HR (staff_members.id) — card1 A.x
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS staff_id INTEGER UNIQUE;
|
||||
|
||||
COMMENT ON COLUMN users.staff_id IS 'id из hr_bot_test.staff_members; без дублирования кадров в clinic_tests';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_staff_id ON users(staff_id) WHERE staff_id IS NOT NULL;
|
||||
@@ -40,3 +40,26 @@ export function getPoolConfig(overrides = {}) {
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Пул к БД `hr_bot_test` (сотрудники, users, RBAC) — отдельно от `clinic_tests`.
|
||||
* Только `HR_DATABASE_URL` (без каскадного fallback на `DATABASE_URL` — путаница опасна).
|
||||
* @param {import('pg').PoolConfig} [overrides]
|
||||
* @returns {import('pg').PoolConfig | null}
|
||||
*/
|
||||
export function getHrPoolConfig(overrides = {}) {
|
||||
const url = process.env.HR_DATABASE_URL?.trim();
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
connectionString: url,
|
||||
max: parseInt(process.env.HR_DB_POOL_MAX || '5', 10),
|
||||
idleTimeoutMillis: parseInt(process.env.DB_IDLE_TIMEOUT || '30000', 10),
|
||||
connectionTimeoutMillis: parseInt(
|
||||
process.env.DB_CONNECTION_TIMEOUT || '2000',
|
||||
10
|
||||
),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
import authRoutes from './routes/auth.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// Middleware
|
||||
app.use(cors({
|
||||
origin: process.env.NODE_ENV === 'production'
|
||||
? process.env.FRONTEND_URL
|
||||
: ['http://localhost:5173', 'http://localhost:3000'],
|
||||
credentials: true,
|
||||
}));
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
|
||||
// Mount auth routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
|
||||
// Health check route
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
message: 'Server is running',
|
||||
});
|
||||
});
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, _next) => {
|
||||
console.error('Error:', err);
|
||||
res.status(err.status || 500).json({
|
||||
error: err.message || 'Internal Server Error',
|
||||
});
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server is running on port ${PORT}`);
|
||||
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Authorization Middleware
|
||||
* JWT authentication and role-based access control
|
||||
*/
|
||||
|
||||
import { verifyToken } from '../utils/auth.js';
|
||||
import { query } from '../db/db.js';
|
||||
|
||||
/**
|
||||
* Extract token from cookie
|
||||
* @param {Object} req - Express request object
|
||||
* @returns {string|null} Token from cookie
|
||||
*/
|
||||
function getTokenFromCookie(req) {
|
||||
return req.cookies?.token || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to authenticate JWT token
|
||||
* Adds user data to req.user
|
||||
*/
|
||||
export async function authenticate(req, res, next) {
|
||||
try {
|
||||
const token = getTokenFromCookie(req);
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const decoded = verifyToken(token);
|
||||
|
||||
if (!decoded) {
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
'SELECT id, login, full_name, role, department_id, staff_id FROM users WHERE id = $1',
|
||||
[decoded.userId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(401).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
const user = result.rows[0];
|
||||
const staffId = user.staff_id ?? decoded.staffId;
|
||||
|
||||
req.user = {
|
||||
id: user.id,
|
||||
login: user.login,
|
||||
fullName: user.full_name,
|
||||
role: user.role,
|
||||
departmentId: user.department_id,
|
||||
};
|
||||
if (staffId != null) {
|
||||
req.user.staffId = staffId;
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Auth middleware error:', error);
|
||||
return res.status(500).json({ error: 'Authentication error' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware factory to require specific roles
|
||||
* @param {string|string[]} roles - Required role(s)
|
||||
* @returns {Function} Express middleware
|
||||
*/
|
||||
export function requireRole(roles) {
|
||||
const allowedRoles = Array.isArray(roles) ? roles : [roles];
|
||||
|
||||
return (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
if (!allowedRoles.includes(req.user.role)) {
|
||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to require specific department access
|
||||
* For managers to access their department's data
|
||||
* @param {number} departmentId - Required department ID
|
||||
* @returns {Function} Express middleware
|
||||
*/
|
||||
export function requireDepartment(departmentId) {
|
||||
return (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
// Admins can access all departments
|
||||
if (req.user.role === 'admin') {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Managers can only access their department
|
||||
if (req.user.role === 'manager' && req.user.departmentId !== departmentId) {
|
||||
return res.status(403).json({ error: 'Access denied to this department' });
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional authentication middleware
|
||||
* Attaches user to request if token is valid, but doesn't require it
|
||||
*/
|
||||
export async function optionalAuth(req, res, next) {
|
||||
try {
|
||||
const token = getTokenFromCookie(req);
|
||||
|
||||
if (!token) {
|
||||
req.user = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
const decoded = verifyToken(token);
|
||||
|
||||
if (!decoded) {
|
||||
req.user = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
'SELECT id, login, full_name, role, department_id, staff_id FROM users WHERE id = $1',
|
||||
[decoded.userId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
req.user = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
const user = result.rows[0];
|
||||
const staffId = user.staff_id ?? decoded.staffId;
|
||||
|
||||
req.user = {
|
||||
id: user.id,
|
||||
login: user.login,
|
||||
fullName: user.full_name,
|
||||
role: user.role,
|
||||
departmentId: user.department_id,
|
||||
};
|
||||
if (staffId != null) {
|
||||
req.user.staffId = staffId;
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
// Don't block request on auth errors
|
||||
req.user = null;
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
authenticate,
|
||||
requireRole,
|
||||
requireDepartment,
|
||||
optionalAuth,
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* A.1–A.4: локальный bcrypt (dev) и HR (HR_AUTH=1 + Werkzeug + staff_id)
|
||||
*/
|
||||
import express from 'express';
|
||||
import { query } from '../db/db.js';
|
||||
import { comparePassword, generateToken } from '../utils/auth.js';
|
||||
import { authenticate } from '../middleware/auth.js';
|
||||
import { queryHr, getHrPool } from '../db/hrPool.js';
|
||||
import { mapHrRoleToApp } from '../utils/hrRoleMap.js';
|
||||
import {
|
||||
isHrAuthEnabled,
|
||||
HR_MANAGED_PASSWORD_PLACEHOLDER,
|
||||
} from '../config/authConstants.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { login, password } = req.body;
|
||||
if (!login || !password) {
|
||||
return res.status(400).json({ error: 'Login and password are required' });
|
||||
}
|
||||
|
||||
if (isHrAuthEnabled()) {
|
||||
if (!getHrPool()) {
|
||||
return res.status(500).json({ error: 'HR_DATABASE_URL is not set' });
|
||||
}
|
||||
const u = await queryHr(
|
||||
`SELECT id, username, password_hash, role
|
||||
FROM users
|
||||
WHERE LOWER(TRIM(username)) = LOWER(TRIM($1))`,
|
||||
[login]
|
||||
);
|
||||
if (u.rows.length === 0 || !u.rows[0].password_hash) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
const row = u.rows[0];
|
||||
const ok = await comparePassword(password, row.password_hash);
|
||||
if (!ok) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
const s = await queryHr(
|
||||
`SELECT id, fio FROM staff_members
|
||||
WHERE LOWER(TRIM(COALESCE(web_login, ''))) = LOWER(TRIM($1))`,
|
||||
[login]
|
||||
);
|
||||
if (s.rows.length === 0) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: 'No staff link for this login (web_login)' });
|
||||
}
|
||||
const staffId = s.rows[0].id;
|
||||
const fio = s.rows[0].fio || login;
|
||||
const appRole = mapHrRoleToApp(row.role);
|
||||
const up = await query(
|
||||
`INSERT INTO users (login, password_hash, full_name, role, department_id, is_active, staff_id)
|
||||
VALUES ($1, $2, $3, $4, null, true, $5)
|
||||
ON CONFLICT (staff_id) DO UPDATE SET
|
||||
login = EXCLUDED.login,
|
||||
full_name = EXCLUDED.full_name,
|
||||
role = EXCLUDED.role,
|
||||
password_hash = EXCLUDED.password_hash
|
||||
RETURNING id, login, full_name, role, department_id, staff_id`,
|
||||
[login, HR_MANAGED_PASSWORD_PLACEHOLDER, fio, appRole, staffId]
|
||||
);
|
||||
const uu = up.rows[0];
|
||||
const token = generateToken(
|
||||
uu.id,
|
||||
uu.role,
|
||||
uu.department_id,
|
||||
{ staffId: uu.staff_id }
|
||||
);
|
||||
res.cookie('token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
return res.json({
|
||||
user: {
|
||||
id: uu.id,
|
||||
login: uu.login,
|
||||
fullName: uu.full_name,
|
||||
role: uu.role,
|
||||
departmentId: uu.department_id,
|
||||
staffId: uu.staff_id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
'SELECT id, login, password_hash, full_name, role, department_id FROM users WHERE login = $1 AND is_active = true',
|
||||
[login]
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
const user = result.rows[0];
|
||||
if (user.password_hash === HR_MANAGED_PASSWORD_PLACEHOLDER) {
|
||||
return res.status(401).json({ error: 'Use HR login' });
|
||||
}
|
||||
const isValidPassword = await comparePassword(password, user.password_hash);
|
||||
if (!isValidPassword) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
const token = generateToken(user.id, user.role, user.department_id);
|
||||
res.cookie('token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
return res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
login: user.login,
|
||||
fullName: user.full_name,
|
||||
role: user.role,
|
||||
departmentId: user.department_id,
|
||||
staffId: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.message?.includes('HR database not configured')) {
|
||||
return res.status(500).json({ error: 'HR database not configured' });
|
||||
}
|
||||
console.error('Login error:', error);
|
||||
return res.status(500).json({ error: 'Login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/logout', (req, res) => {
|
||||
try {
|
||||
res.clearCookie('token', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
});
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
res.status(500).json({ error: 'Logout failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/me', authenticate, async (req, res) => {
|
||||
try {
|
||||
res.json({ user: req.user });
|
||||
} catch (error) {
|
||||
console.error('Get current user error:', error);
|
||||
res.status(500).json({ error: 'Failed to get user data' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* V.4–V.6, D.1 — API тестов, версий, импорт файла
|
||||
*/
|
||||
import express from 'express';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import multer from 'multer';
|
||||
import pool, { query } from '../db/db.js';
|
||||
import { authenticate } from '../middleware/auth.js';
|
||||
import { hasAnyAttemptForTest } from '../services/testChainService.js';
|
||||
import { saveTestDraft, createTestWithVersion } from '../services/testDraftService.js';
|
||||
|
||||
const router = express.Router();
|
||||
const upload = multer({
|
||||
dest: os.tmpdir(),
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
function asyncHandler(fn) {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch((err) => {
|
||||
if (err.status) {
|
||||
res.status(err.status).json({ error: err.message });
|
||||
} else {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** D.1 — раньше /:id, иначе id = "import" */
|
||||
router.post(
|
||||
'/import/document',
|
||||
authenticate,
|
||||
upload.single('file'),
|
||||
asyncHandler(async (req, res) => {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'file field required' });
|
||||
}
|
||||
const p = req.file.path;
|
||||
let size = 0;
|
||||
try {
|
||||
const st = await fs.stat(p);
|
||||
size = st.size;
|
||||
await fs.unlink(p);
|
||||
} catch {
|
||||
try {
|
||||
await fs.unlink(p);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return res.status(500).json({ error: 'upload failed' });
|
||||
}
|
||||
res.json({
|
||||
received: true,
|
||||
originalName: req.file.originalname,
|
||||
size,
|
||||
message:
|
||||
'Файл принят; извлечение текста и генерация (D.2–D.3) — в следующем шаге',
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
authenticate,
|
||||
asyncHandler(async (req, res) => {
|
||||
const { rows } = await query(
|
||||
`SELECT t.id, t.title, t.description, t.is_active AS chain_active,
|
||||
t.created_at, t.updated_at, tv.id AS active_version_id, tv.version
|
||||
FROM tests t
|
||||
INNER JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true
|
||||
WHERE t.is_active = true
|
||||
ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC`
|
||||
);
|
||||
res.json({ tests: rows });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
authenticate,
|
||||
asyncHandler(async (req, res) => {
|
||||
const { title, description } = req.body;
|
||||
if (!title || typeof title !== 'string') {
|
||||
return res.status(400).json({ error: 'title required' });
|
||||
}
|
||||
const out = await createTestWithVersion(pool, req.user.id, {
|
||||
title,
|
||||
description,
|
||||
});
|
||||
res.status(201).json(out);
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:id/versions',
|
||||
authenticate,
|
||||
asyncHandler(async (req, res) => {
|
||||
const testId = req.params.id;
|
||||
const { rows: t } = await query(`SELECT id, created_by FROM tests WHERE id = $1`, [
|
||||
testId,
|
||||
]);
|
||||
if (!t.length) {
|
||||
return res.status(404).json({ error: 'Test not found' });
|
||||
}
|
||||
if (t[0].created_by !== req.user.id) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
const { rows } = await query(
|
||||
`SELECT id, version, is_active, parent_id, created_at
|
||||
FROM test_versions WHERE test_id = $1 ORDER BY version`,
|
||||
[testId]
|
||||
);
|
||||
const hasAttempts = await hasAnyAttemptForTest(pool, testId);
|
||||
res.json({ versions: rows, hasAttempts });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/versions/:vid/activate',
|
||||
authenticate,
|
||||
asyncHandler(async (req, res) => {
|
||||
const testId = req.params.id;
|
||||
const versionId = req.params.vid;
|
||||
const { rows: t } = await query(
|
||||
`SELECT id, created_by FROM tests WHERE id = $1`,
|
||||
[testId]
|
||||
);
|
||||
if (!t.length) {
|
||||
return res.status(404).json({ error: 'Test not found' });
|
||||
}
|
||||
if (t[0].created_by !== req.user.id) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
const { rows: v } = await query(
|
||||
`SELECT id FROM test_versions WHERE test_id = $1 AND id = $2`,
|
||||
[testId, versionId]
|
||||
);
|
||||
if (!v.length) {
|
||||
return res.status(404).json({ error: 'Version not found' });
|
||||
}
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query(
|
||||
`UPDATE test_versions SET is_active = false WHERE test_id = $1`,
|
||||
[testId]
|
||||
);
|
||||
await client.query(
|
||||
`UPDATE test_versions SET is_active = true WHERE id = $1`,
|
||||
[versionId]
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
res.json({ ok: true, activeVersionId: versionId });
|
||||
})
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/:id',
|
||||
authenticate,
|
||||
asyncHandler(async (req, res) => {
|
||||
const testId = req.params.id;
|
||||
const { isActive, chainActive } = req.body;
|
||||
const chain = chainActive ?? isActive;
|
||||
if (typeof chain !== 'boolean') {
|
||||
return res.status(400).json({ error: 'chainActive (boolean) required' });
|
||||
}
|
||||
const { rows: t } = await query(
|
||||
`SELECT id, created_by FROM tests WHERE id = $1`,
|
||||
[testId]
|
||||
);
|
||||
if (!t.length) {
|
||||
return res.status(404).json({ error: 'Test not found' });
|
||||
}
|
||||
if (t[0].created_by !== req.user.id) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
await query(
|
||||
`UPDATE tests SET is_active = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1`,
|
||||
[testId, chain]
|
||||
);
|
||||
res.json({ id: testId, chainActive: chain });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/draft',
|
||||
authenticate,
|
||||
asyncHandler(async (req, res) => {
|
||||
const out = await saveTestDraft(pool, req.user.id, req.params.id, req.body);
|
||||
res.json(out);
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/attempts/start',
|
||||
authenticate,
|
||||
asyncHandler(async (req, res) => {
|
||||
const testId = req.params.id;
|
||||
const { rows: tv } = await query(
|
||||
`SELECT tv.id AS test_version_id
|
||||
FROM test_versions tv
|
||||
WHERE tv.test_id = $1 AND tv.is_active = true LIMIT 1`,
|
||||
[testId]
|
||||
);
|
||||
if (!tv.length) {
|
||||
return res.status(404).json({ error: 'No active version' });
|
||||
}
|
||||
const testVersionId = tv[0].test_version_id;
|
||||
const { rows: mx } = await query(
|
||||
`SELECT COALESCE(MAX(attempt_number), 0) AS n FROM test_attempts
|
||||
WHERE test_version_id = $1 AND user_id = $2`,
|
||||
[testVersionId, req.user.id]
|
||||
);
|
||||
const nextN = (mx[0].n || 0) + 1;
|
||||
const { rows: a } = await query(
|
||||
`INSERT INTO test_attempts (test_version_id, user_id, attempt_number, status)
|
||||
VALUES ($1, $2, $3, 'in_progress')
|
||||
RETURNING id, test_version_id, user_id, attempt_number, status, started_at`,
|
||||
[testVersionId, req.user.id, nextN]
|
||||
);
|
||||
res.status(201).json({ attempt: a[0] });
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:id/chain-info',
|
||||
authenticate,
|
||||
asyncHandler(async (req, res) => {
|
||||
const testId = req.params.id;
|
||||
const has = await hasAnyAttemptForTest(pool, testId);
|
||||
res.json({ testId, hasAnyAttempt: has });
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createApp } from './app.js';
|
||||
|
||||
const app = createApp();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server is running on port ${PORT}`);
|
||||
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* V.3 saveTestDraft, fork версии, контент вопросов.
|
||||
*/
|
||||
import { hasAnyAttemptForTest } from './testChainService.js';
|
||||
|
||||
/**
|
||||
* @param {import('pg').PoolClient} client
|
||||
* @param {string} testId
|
||||
*/
|
||||
export async function getActiveVersionRow(client, testId) {
|
||||
const { rows } = await client.query(
|
||||
`SELECT * FROM test_versions WHERE test_id = $1 AND is_active = true LIMIT 1`,
|
||||
[testId]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('pg').PoolClient} client
|
||||
* @param {string} fromVersionId
|
||||
* @param {string} toVersionId
|
||||
*/
|
||||
export async function copyQuestionTree(client, fromVersionId, toVersionId) {
|
||||
const { rows: questions } = await client.query(
|
||||
`SELECT * FROM questions WHERE test_version_id = $1 ORDER BY question_order`,
|
||||
[fromVersionId]
|
||||
);
|
||||
for (const q of questions) {
|
||||
const { rows: insQ } = await client.query(
|
||||
`INSERT INTO questions (test_version_id, text, question_order, has_multiple_answers)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||
[toVersionId, q.text, q.question_order, q.has_multiple_answers]
|
||||
);
|
||||
const nqid = insQ[0].id;
|
||||
const { rows: options } = await client.query(
|
||||
`SELECT * FROM answer_options WHERE question_id = $1 ORDER BY option_order`,
|
||||
[q.id]
|
||||
);
|
||||
for (const o of options) {
|
||||
await client.query(
|
||||
`INSERT INTO answer_options (question_id, text, is_correct, option_order)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[nqid, o.text, o.is_correct, o.option_order]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('pg').PoolClient} client
|
||||
* @param {string} testVersionId
|
||||
* @param {{ questions?: Array<{ text: string, question_order?: number, hasMultipleAnswers?: boolean, options?: Array<{ text: string, isCorrect?: boolean, option_order?: number }> }> }} payload
|
||||
*/
|
||||
export async function replaceVersionContent(client, testVersionId, payload) {
|
||||
await client.query(
|
||||
`DELETE FROM answer_options WHERE question_id IN
|
||||
(SELECT id FROM questions WHERE test_version_id = $1)`,
|
||||
[testVersionId]
|
||||
);
|
||||
await client.query(`DELETE FROM questions WHERE test_version_id = $1`, [
|
||||
testVersionId,
|
||||
]);
|
||||
const questions = payload.questions || [];
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const q = questions[i];
|
||||
const { rows: insQ } = await client.query(
|
||||
`INSERT INTO questions (test_version_id, text, question_order, has_multiple_answers)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||
[
|
||||
testVersionId,
|
||||
q.text,
|
||||
q.question_order ?? i + 1,
|
||||
q.hasMultipleAnswers || false,
|
||||
]
|
||||
);
|
||||
const qid = insQ[0].id;
|
||||
const opts = q.options || [];
|
||||
for (let j = 0; j < opts.length; j++) {
|
||||
const o = opts[j];
|
||||
await client.query(
|
||||
`INSERT INTO answer_options (question_id, text, is_correct, option_order)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[qid, o.text, !!o.isCorrect, o.option_order ?? j + 1]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('pg').PoolClient} client
|
||||
* @param {string} testId
|
||||
*/
|
||||
export async function forkNewVersion(client, testId) {
|
||||
const av = await getActiveVersionRow(client, testId);
|
||||
if (!av) {
|
||||
throw new Error('no active version');
|
||||
}
|
||||
const { rows: mx } = await client.query(
|
||||
`SELECT COALESCE(MAX(version), 0) AS v FROM test_versions WHERE test_id = $1`,
|
||||
[testId]
|
||||
);
|
||||
const nextV = (mx[0].v || 0) + 1;
|
||||
const { rows: nv } = await client.query(
|
||||
`INSERT INTO test_versions (test_id, version, is_active, parent_id)
|
||||
VALUES ($1, $2, true, $3) RETURNING *`,
|
||||
[testId, nextV, av.id]
|
||||
);
|
||||
const newRow = nv[0];
|
||||
await client.query(
|
||||
`UPDATE test_versions SET is_active = false WHERE test_id = $1 AND id <> $2`,
|
||||
[testId, newRow.id]
|
||||
);
|
||||
await copyQuestionTree(client, av.id, newRow.id);
|
||||
return newRow;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('pg').Pool} pool
|
||||
* @param {string} authorId
|
||||
* @param {string} testId
|
||||
* @param {{ title?: string, description?: string, questions?: Array<unknown> }} payload
|
||||
*/
|
||||
export async function saveTestDraft(pool, authorId, testId, payload) {
|
||||
const { rows: tr } = await pool.query(
|
||||
`SELECT id, created_by FROM tests WHERE id = $1`,
|
||||
[testId]
|
||||
);
|
||||
if (!tr.length) {
|
||||
const e = new Error('Test not found');
|
||||
e.status = 404;
|
||||
throw e;
|
||||
}
|
||||
const t = tr[0];
|
||||
if (t.created_by !== authorId) {
|
||||
const e = new Error('Forbidden');
|
||||
e.status = 403;
|
||||
throw e;
|
||||
}
|
||||
|
||||
const client = await pool.connect();
|
||||
let forked = false;
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
if (payload.title != null || payload.description != null) {
|
||||
await client.query(
|
||||
`UPDATE tests SET title = COALESCE($2, title), description = COALESCE($3, description),
|
||||
updated_at = CURRENT_TIMESTAMP WHERE id = $1`,
|
||||
[testId, payload.title ?? null, payload.description ?? null]
|
||||
);
|
||||
}
|
||||
const hasAttempts = await hasAnyAttemptForTest(client, testId);
|
||||
let versionRow = await getActiveVersionRow(client, testId);
|
||||
if (!versionRow) {
|
||||
const e = new Error('No active version');
|
||||
e.status = 500;
|
||||
throw e;
|
||||
}
|
||||
if (hasAttempts && payload.questions !== undefined) {
|
||||
versionRow = await forkNewVersion(client, testId);
|
||||
forked = true;
|
||||
}
|
||||
if (payload.questions) {
|
||||
await replaceVersionContent(client, versionRow.id, payload);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return { testId, versionId: versionRow.id, forked };
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать пустой тест (цепочка) с одной версией 1.
|
||||
* @param {import('pg').Pool} pool
|
||||
* @param {string} authorId
|
||||
* @param {{ title: string, description?: string }} meta
|
||||
*/
|
||||
export async function createTestWithVersion(pool, authorId, meta) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rows: t } = await client.query(
|
||||
`INSERT INTO tests (title, description, created_by, is_active, is_versioned)
|
||||
VALUES ($1, $2, $3, true, true) RETURNING id`,
|
||||
[meta.title, meta.description || null, authorId]
|
||||
);
|
||||
const testId = t[0].id;
|
||||
const { rows: v } = await client.query(
|
||||
`INSERT INTO test_versions (test_id, version, is_active, parent_id)
|
||||
VALUES ($1, 1, true, NULL) RETURNING id`,
|
||||
[testId]
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
return { testId, versionId: v[0].id };
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user