diff --git a/.gitignore b/.gitignore index a5ac6d2..60efdb1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ server.log *.pdf *.txt package-lock.json -*.zip \ No newline at end of file +*.zip +.env \ No newline at end of file diff --git a/auth.js b/auth.js new file mode 100644 index 0000000..a565a23 --- /dev/null +++ b/auth.js @@ -0,0 +1,61 @@ +const bcrypt = require('bcryptjs'); + +class AuthService { + constructor() { + this.db = null; + } + + setDatabase(database) { + this.db = database; + this.initUsers(); + } + + initUsers() { + if (!this.db) return; + const users = [ + { + login: process.env.USER_1_LOGIN, + password: process.env.USER_1_PASSWORD, + name: process.env.USER_1_NAME, + email: process.env.USER_1_EMAIL, + role: process.env.USER_1_ROLE || 'admin' + } + ]; + for (const u of users) { + if (u.login && u.password) { + this.createUserIfNotExists(u); + } + } + } + + async createUserIfNotExists(userData) { + const row = this.db.prepare('SELECT id FROM users WHERE login = ?').get(userData.login); + if (!row) { + const hashedPassword = await bcrypt.hash(userData.password, 10); + this.db.prepare( + 'INSERT INTO users (login, password, name, email, role, auth_type, created_at) VALUES (?, ?, ?, ?, ?, ?, datetime(\'now\'))' + ).run(userData.login, hashedPassword, userData.name, userData.email || null, userData.role, 'local'); + console.log('Создан пользователь: ' + userData.name); + } + } + + authenticate(login, password) { + const user = this.db.prepare("SELECT * FROM users WHERE login = ? AND auth_type = 'local'").get(login); + if (!user) return null; + const isValid = bcrypt.compareSync(password, user.password); + if (isValid) { + this.db.prepare("UPDATE users SET last_login = datetime('now') WHERE id = ?").run(user.id); + const { password, ...userWithoutPassword } = user; + return userWithoutPassword; + } + return null; + } + + getUserById(id) { + const user = this.db.prepare('SELECT id, login, name, email, role, auth_type, created_at, last_login FROM users WHERE id = ?').get(id); + return user || null; + } +} + +const authService = new AuthService(); +module.exports = authService; diff --git a/package.json b/package.json index 7fde416..0febb7f 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,12 @@ "type": "commonjs", "dependencies": { "adm-zip": "^0.5.17", + "bcryptjs": "^2.4.3", "better-sqlite3": "^12.11.1", + "connect-sqlite3": "^0.9.16", + "dotenv": "^16.3.1", "express": "^4.22.2", + "express-session": "^1.18.2", "multer": "^2.2.0", "pdf-parse": "^2.4.5" } diff --git a/public/app.js b/public/app.js index 4ee45d0..77b0a40 100644 --- a/public/app.js +++ b/public/app.js @@ -1,3 +1,26 @@ +if (typeof window.fetch === 'function' && !window.__fetchPatched) { + window.__fetchPatched = true; + const _origFetch = window.fetch.bind(window); + window.fetch = function(input, init) { + init = init || {}; + init.credentials = 'include'; + init.cache = 'no-store'; + const method = (init.method || 'GET').toUpperCase(); + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) { + const token = window.__CSRF_TOKEN; + if (token) { + if (!init.headers) init.headers = {}; + if (typeof init.headers === 'object' && !(init.headers instanceof Headers)) { + init.headers['X-CSRF-Token'] = token; + } else if (init.headers instanceof Headers) { + init.headers.set('X-CSRF-Token', token); + } + } + } + return _origFetch(input, init); + }; +} + const dropZone = document.getElementById('dropZone'); const fileInput = document.getElementById('fileInput'); const uploadBtn = document.getElementById('uploadBtn'); @@ -804,5 +827,7 @@ function renderDvoikiCards(rows) { }).join(''); } -loadFilters(); -loadAll(); +document.addEventListener('DOMContentLoaded', function() { + document.getElementById('login-form').addEventListener('submit', login); + checkAuth(); +}); diff --git a/public/auth.js b/public/auth.js new file mode 100644 index 0000000..fe85105 --- /dev/null +++ b/public/auth.js @@ -0,0 +1,136 @@ +let currentUser = null; + +function getCookie(name) { + const match = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)'); + return match ? match[2] : null; +} + +function setCookie(name, value, maxAgeSec) { + document.cookie = name + '=' + value + ';path=/;max-age=' + maxAgeSec; +} + +function getLockedUntil() { return parseInt(getCookie('lockout_until') || '0', 10); } +function setLockedUntil(ts) { setCookie('lockout_until', ts, 300); } +function getLoginAttempts() { return parseInt(getCookie('login_attempts') || '0', 10); } +function setLoginAttempts(count) { setCookie('login_attempts', count, 300); } +function clearLoginCookies() { + setCookie('login_attempts', 0, 0); + setCookie('lockout_until', 0, 0); +} + +function isLocked() { + return Date.now() < getLockedUntil(); +} + +function getRemainingLockoutSeconds() { + const rem = Math.floor((getLockedUntil() - Date.now()) / 1000); + return Math.max(0, rem); +} + +async function checkAuth() { + try { + const response = await fetch('/api/user', { credentials: 'include' }); + if (response.ok) { + const data = await response.json(); + currentUser = data.user; + if (data.csrfToken) { + window.__CSRF_TOKEN = data.csrfToken; + } + showMainInterface(); + } else { + showLoginInterface(); + } + } catch (error) { + showLoginInterface(); + } +} + +function showLoginInterface() { + document.getElementById('app').style.display = 'none'; + document.getElementById('login-screen').style.display = 'flex'; +} + +function showMainInterface() { + document.getElementById('login-screen').style.display = 'none'; + document.getElementById('app').style.display = 'block'; + const userEl = document.getElementById('currentUser'); + if (userEl && currentUser) { + userEl.textContent = (currentUser.role === 'admin' ? '👑 ' : '') + currentUser.name; + } + if (typeof loadFilters === 'function') loadFilters(); + if (typeof loadAll === 'function') loadAll(); +} + +async function login(event) { + event.preventDefault(); + if (isLocked()) { + const rem = getRemainingLockoutSeconds(); + document.getElementById('login-error').textContent = 'Слишком много попыток. Подождите ' + rem + ' секунд.'; + document.getElementById('login-error').style.display = 'block'; + document.getElementById('login-attempts').textContent = ''; + return; + } + + const login = document.getElementById('login').value; + const password = document.getElementById('password').value; + const errorEl = document.getElementById('login-error'); + const attemptsEl = document.getElementById('login-attempts'); + + try { + const response = await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ login, password }) + }); + + if (response.ok) { + clearLoginCookies(); + const data = await response.json(); + currentUser = data.user; + if (data.csrfToken) { + window.__CSRF_TOKEN = data.csrfToken; + } + showMainInterface(); + } else if (response.status === 429) { + const data = await response.json(); + errorEl.textContent = data.error; + errorEl.style.display = 'block'; + } else { + let attempts = getLoginAttempts() + 1; + setLoginAttempts(attempts); + if (attempts >= 10) { + setLockedUntil(Date.now() + 300000); + errorEl.textContent = 'Слишком много попыток. Подождите 5 минут.'; + attemptsEl.textContent = ''; + } else { + errorEl.textContent = 'Неверный логин или пароль'; + attemptsEl.textContent = 'Попыток: ' + attempts + ' из 10'; + } + errorEl.style.display = 'block'; + } + } catch (error) { + document.getElementById('login-error').textContent = 'Ошибка соединения'; + document.getElementById('login-error').style.display = 'block'; + } +} + +async function logout() { + try { + await fetch('/api/logout', { method: 'POST' }); + } catch (e) {} + currentUser = null; + window.__CSRF_TOKEN = null; + showLoginInterface(); +} + +function togglePasswordVisibility() { + const pwd = document.getElementById('password'); + const icon = document.querySelector('.password-toggle i'); + if (pwd.type === 'password') { + pwd.type = 'text'; + if (icon) icon.className = 'fas fa-eye-slash'; + } else { + pwd.type = 'password'; + if (icon) icon.className = 'fas fa-eye'; + } +} diff --git a/public/index.html b/public/index.html index 62f407a..ea44027 100644 --- a/public/index.html +++ b/public/index.html @@ -5,11 +5,47 @@
Войдите в свою учётную запись
+ +