Files
ldap-auth-service/lib/requests.js
2026-09-01 11:25:56 +05:00

373 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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
};