373 lines
12 KiB
JavaScript
373 lines
12 KiB
JavaScript
'use strict';
|
||
|
||
const crypto = require('crypto');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const adminSettings = require('./adminSettings');
|
||
const ldapClient = require('./ldapClient');
|
||
const { translit } = require('./translit');
|
||
|
||
const requestsPath = path.join(__dirname, '..', 'data', 'registration-requests.json');
|
||
const CODE_TTL_MS = 10 * 60 * 1000;
|
||
const MAX_CODE_ATTEMPTS = 5;
|
||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
const NAME_RE = /^[А-Яа-яЁё][А-Яа-яЁё\s-]*$/;
|
||
const LOGIN_RE = /^[A-Za-z0-9._-]{1,20}$/;
|
||
const PHONE_RE = /^(\+7|8)[\s(-]*(\d{3})[\s)-]*(\d{3})[\s-]*(\d{2})[\s-]*(\d{2})$/;
|
||
|
||
function normalizePosition(value) {
|
||
return String(value || '').trim().slice(0, 120);
|
||
}
|
||
|
||
function normalizePhone(value) {
|
||
const trimmed = String(value || '').trim();
|
||
if (!trimmed) return '';
|
||
const m = PHONE_RE.exec(trimmed);
|
||
if (!m) return null;
|
||
return `+7${m[2]}${m[3]}${m[4]}${m[5]}`;
|
||
}
|
||
|
||
function validatePhone(value, required) {
|
||
const normalized = normalizePhone(value);
|
||
if (normalized === null) {
|
||
const err = new Error('Некорректный номер телефона');
|
||
err.status = 400;
|
||
throw err;
|
||
}
|
||
if (required && !normalized) {
|
||
const err = new Error('Номер телефона обязателен');
|
||
err.status = 400;
|
||
throw err;
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
function readRequests() {
|
||
try {
|
||
const parsed = JSON.parse(fs.readFileSync(requestsPath, 'utf8'));
|
||
return Array.isArray(parsed) ? parsed : [];
|
||
} catch (err) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function writeRequests(requests) {
|
||
fs.mkdirSync(path.dirname(requestsPath), { recursive: true });
|
||
fs.writeFileSync(requestsPath, JSON.stringify(requests, null, 2), 'utf8');
|
||
}
|
||
|
||
function listRequests() {
|
||
const all = readRequests();
|
||
const pending = all
|
||
.filter((r) => r.phase === 'pending')
|
||
.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
|
||
const history = all
|
||
.filter((r) => r.phase === 'approved' || r.phase === 'denied')
|
||
.sort((a, b) => (b.processedAt || 0) - (a.processedAt || 0));
|
||
return { pending, history };
|
||
}
|
||
|
||
function getRequest(id) {
|
||
return readRequests().find((r) => r.id === id) || null;
|
||
}
|
||
|
||
function findByEmail(email) {
|
||
const needle = String(email || '').toLowerCase();
|
||
return readRequests().find((r) => r.email && r.email.toLowerCase() === needle) || null;
|
||
}
|
||
|
||
function findVerifyingByEmail(email) {
|
||
const needle = String(email || '').toLowerCase();
|
||
return readRequests().find((r) => r.phase === 'verifying' && r.email && r.email.toLowerCase() === needle) || null;
|
||
}
|
||
|
||
function saveRequest(record) {
|
||
const all = readRequests();
|
||
const idx = all.findIndex((r) => r.id === record.id);
|
||
if (idx >= 0) all[idx] = record;
|
||
else all.push(record);
|
||
writeRequests(all);
|
||
return record;
|
||
}
|
||
|
||
function generateLogin(surname, name, takenSet) {
|
||
const used = new Set((takenSet || []).map((l) => String(l).toLowerCase()));
|
||
const surnamePart = translit(surname);
|
||
let initial = translit(String(name || '').charAt(0));
|
||
if (!initial) initial = translit(name);
|
||
if (!initial) initial = 'x';
|
||
let candidate = `${surnamePart}.${initial}`;
|
||
if (!candidate || used.has(candidate)) {
|
||
let n = 2;
|
||
while (used.has(`${surnamePart}.${initial}${n}`)) n += 1;
|
||
candidate = `${surnamePart}.${initial}${n}`;
|
||
}
|
||
return candidate;
|
||
}
|
||
|
||
function validatePassword(password) {
|
||
if (typeof password !== 'string' || password.length < 10) {
|
||
return 'Пароль должен содержать не менее 10 символов';
|
||
}
|
||
if (!/[a-zа-я]/.test(password)) return 'Пароль должен содержать строчную букву';
|
||
if (!/[A-ZА-Я]/.test(password)) return 'Пароль должен содержать заглавную букву';
|
||
if (!/[0-9]/.test(password)) return 'Пароль должен содержать цифру';
|
||
return null;
|
||
}
|
||
|
||
function issueCode(email) {
|
||
let record = findVerifyingByEmail(email);
|
||
if (!record) {
|
||
record = {
|
||
id: crypto.randomBytes(8).toString('hex'),
|
||
phase: 'verifying',
|
||
email: String(email).toLowerCase(),
|
||
emailVerified: false,
|
||
createdAt: Date.now()
|
||
};
|
||
}
|
||
const code = String(Math.floor(100000 + Math.random() * 900000));
|
||
const codeNumber = 1 + Math.floor(Math.random() * 4094);
|
||
record.codeHash = crypto.createHash('sha256').update(code).digest('hex');
|
||
record.codeExpiresAt = Date.now() + CODE_TTL_MS;
|
||
record.codeNumber = codeNumber;
|
||
record.attempts = 0;
|
||
record.emailVerified = false;
|
||
saveRequest(record);
|
||
return { record, code, codeNumber };
|
||
}
|
||
|
||
function verifyCode(email, code) {
|
||
const record = findVerifyingByEmail(email);
|
||
if (!record) {
|
||
const err = new Error('Код не запрашивался для этой почты');
|
||
err.status = 400;
|
||
throw err;
|
||
}
|
||
if (record.emailVerified) return record;
|
||
if (Date.now() > (record.codeExpiresAt || 0)) {
|
||
const err = new Error('Код истёк, запросите новый');
|
||
err.status = 400;
|
||
throw err;
|
||
}
|
||
if ((record.attempts || 0) >= MAX_CODE_ATTEMPTS) {
|
||
const err = new Error('Слишком много попыток, запросите новый код');
|
||
err.status = 400;
|
||
throw err;
|
||
}
|
||
const hash = crypto.createHash('sha256').update(String(code || '')).digest('hex');
|
||
if (hash !== record.codeHash) {
|
||
record.attempts = (record.attempts || 0) + 1;
|
||
saveRequest(record);
|
||
const err = new Error('Неверный код');
|
||
err.status = 400;
|
||
throw err;
|
||
}
|
||
record.emailVerified = true;
|
||
record.codeHash = undefined;
|
||
record.codeExpiresAt = undefined;
|
||
record.attempts = 0;
|
||
saveRequest(record);
|
||
return record;
|
||
}
|
||
|
||
function submitRequest({ surname, name, patronymic, email, password, phone, position }, takenSet) {
|
||
const record = findVerifyingByEmail(email);
|
||
if (!record || !record.emailVerified) {
|
||
const err = new Error('Почта не подтверждена — запросите и введите код');
|
||
err.status = 400;
|
||
throw err;
|
||
}
|
||
const passwordError = validatePassword(password);
|
||
if (passwordError) {
|
||
const err = new Error(passwordError);
|
||
err.status = 400;
|
||
throw err;
|
||
}
|
||
const phoneRequired = !!adminSettings.getSettings().phoneRequired;
|
||
const normalizedPhone = validatePhone(phone, phoneRequired);
|
||
const fio = [surname, name, patronymic].filter(Boolean).join(' ');
|
||
const login = generateLogin(surname, name, takenSet);
|
||
record.surname = surname;
|
||
record.name = name;
|
||
record.patronymic = patronymic || '';
|
||
record.fio = fio;
|
||
record.login = login;
|
||
record.password = password;
|
||
record.phone = normalizedPhone;
|
||
record.position = normalizePosition(position);
|
||
record.groups = (adminSettings.getSettings().defaultGroups || []).slice();
|
||
record.groupsEdited = false;
|
||
record.type = 'registration';
|
||
record.phase = 'pending';
|
||
record.createdAt = Date.now();
|
||
record.codeHash = undefined;
|
||
record.codeExpiresAt = undefined;
|
||
record.attempts = 0;
|
||
saveRequest(record);
|
||
return record;
|
||
}
|
||
|
||
function validateFio(surname, name, patronymic) {
|
||
if (!surname || !name) {
|
||
const err = new Error('Фамилия и имя обязательны');
|
||
err.status = 400;
|
||
throw err;
|
||
}
|
||
if (!NAME_RE.test(surname) || !NAME_RE.test(name) || (patronymic && !NAME_RE.test(patronymic))) {
|
||
const err = new Error('ФИО должно содержать только русские буквы');
|
||
err.status = 400;
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
async function submitPasswordChangeRequest({ surname, name, patronymic, login, email, password, phone, position }) {
|
||
const record = findVerifyingByEmail(email);
|
||
if (!record || !record.emailVerified) {
|
||
const err = new Error('Почта не подтверждена — запросите и введите код');
|
||
err.status = 400;
|
||
throw err;
|
||
}
|
||
const passwordError = validatePassword(password);
|
||
if (passwordError) {
|
||
const err = new Error(passwordError);
|
||
err.status = 400;
|
||
throw err;
|
||
}
|
||
const phoneRequired = !!adminSettings.getSettings().phoneRequired;
|
||
const normalizedPhone = validatePhone(phone, phoneRequired);
|
||
validateFio(surname, name, patronymic);
|
||
const normalizedLogin = String(login || '').trim();
|
||
if (!normalizedLogin) {
|
||
const err = new Error('Укажите логин');
|
||
err.status = 400;
|
||
throw err;
|
||
}
|
||
const user = await ldapClient.getUser(normalizedLogin);
|
||
const fio = [surname, name, patronymic].filter(Boolean).join(' ');
|
||
record.surname = surname;
|
||
record.name = name;
|
||
record.patronymic = patronymic || '';
|
||
record.fio = fio;
|
||
record.login = user.login || normalizedLogin;
|
||
record.password = password;
|
||
record.phone = normalizedPhone;
|
||
record.position = normalizePosition(position);
|
||
record.currentGroups = user.groups || [];
|
||
record.type = 'password-change';
|
||
record.phase = 'pending';
|
||
record.createdAt = Date.now();
|
||
record.codeHash = undefined;
|
||
record.codeExpiresAt = undefined;
|
||
record.attempts = 0;
|
||
saveRequest(record);
|
||
return record;
|
||
}
|
||
|
||
function updateRequest(id, patch, takenSet) {
|
||
const record = getRequest(id);
|
||
if (!record) {
|
||
const err = new Error('Заявка не найдена');
|
||
err.status = 404;
|
||
throw err;
|
||
}
|
||
if (record.phase !== 'pending') {
|
||
const err = new Error('Заявка уже обработана');
|
||
err.status = 409;
|
||
throw err;
|
||
}
|
||
if (patch.email !== undefined) {
|
||
if (!EMAIL_RE.test(String(patch.email))) {
|
||
const err = new Error('Некорректный email');
|
||
err.status = 400;
|
||
throw err;
|
||
}
|
||
record.email = String(patch.email).toLowerCase();
|
||
}
|
||
if (patch.password !== undefined && patch.password !== '') {
|
||
const passwordError = validatePassword(patch.password);
|
||
if (passwordError) {
|
||
const err = new Error(passwordError);
|
||
err.status = 400;
|
||
throw err;
|
||
}
|
||
record.password = patch.password;
|
||
}
|
||
if (patch.phone !== undefined) {
|
||
const phoneRequired = !!adminSettings.getSettings().phoneRequired;
|
||
record.phone = validatePhone(patch.phone, phoneRequired);
|
||
}
|
||
if (patch.position !== undefined) {
|
||
record.position = normalizePosition(patch.position);
|
||
}
|
||
if (patch.login !== undefined && record.type !== 'password-change') {
|
||
const login = String(patch.login).trim();
|
||
if (!login) {
|
||
const err = new Error('Укажите логин');
|
||
err.status = 400;
|
||
throw err;
|
||
}
|
||
if (!LOGIN_RE.test(login)) {
|
||
const err = new Error('Логин может содержать только латинские буквы, цифры и символы . _ - (до 20 символов)');
|
||
err.status = 400;
|
||
throw err;
|
||
}
|
||
if ((takenSet || []).some((l) => String(l).toLowerCase() === login.toLowerCase())) {
|
||
const err = new Error(`Логин "${login}" уже занят`);
|
||
err.status = 400;
|
||
throw err;
|
||
}
|
||
record.login = login;
|
||
}
|
||
function validateName(field, value) {
|
||
const trimmed = String(value || '').trim();
|
||
if (!trimmed || !NAME_RE.test(trimmed)) {
|
||
const err = new Error(field === 'patronymic' ? 'Отчество должно содержать только русские буквы' : 'ФИО должно содержать только русские буквы');
|
||
err.status = 400;
|
||
throw err;
|
||
}
|
||
return trimmed;
|
||
}
|
||
if (patch.surname !== undefined) record.surname = validateName('surname', patch.surname);
|
||
if (patch.name !== undefined) record.name = validateName('name', patch.name);
|
||
if (patch.patronymic !== undefined) record.patronymic = patch.patronymic ? validateName('patronymic', patch.patronymic) : '';
|
||
if (patch.surname !== undefined || patch.name !== undefined) {
|
||
const fio = [record.surname, record.name, record.patronymic].filter(Boolean).join(' ');
|
||
record.fio = fio;
|
||
if (record.type !== 'password-change' && patch.login === undefined) {
|
||
record.login = generateLogin(record.surname, record.name, takenSet);
|
||
}
|
||
}
|
||
if (Array.isArray(patch.groupCNs)) {
|
||
record.groups = patch.groupCNs.filter((g) => typeof g === 'string');
|
||
record.groupsEdited = true;
|
||
}
|
||
saveRequest(record);
|
||
return record;
|
||
}
|
||
|
||
function markProcessed(id, patch) {
|
||
const record = getRequest(id);
|
||
if (!record) return null;
|
||
Object.assign(record, patch, { processedAt: Date.now() });
|
||
saveRequest(record);
|
||
return record;
|
||
}
|
||
|
||
module.exports = {
|
||
NAME_RE,
|
||
normalizePhone,
|
||
validatePhone,
|
||
normalizePosition,
|
||
listRequests,
|
||
getRequest,
|
||
findByEmail,
|
||
generateLogin,
|
||
validatePassword,
|
||
issueCode,
|
||
verifyCode,
|
||
submitRequest,
|
||
submitPasswordChangeRequest,
|
||
updateRequest,
|
||
markProcessed
|
||
}; |