JS Регистрация и авторизация на CEF + MySQL

  • Автор темы Автор темы Lev Angel
  • Дата начала Дата начала
  • Featured
Сегодня мы с Вами напишем с нуля полноценный скрипт регистрации и авторизации для сервера rage mp. В качестве интерфейса мы не будем использовать команды, а сразу сделаем "красиво" на CEF. В качестве базы данных будем использовать MySQL.


Видео версия как обычно на youtube канале:
Видео версия урока

Для начала я нашел в Интернете простенький HTML шаблон страницы авторизации: https://codepen.io/colorlib/pen/rxddKy
Помещаем его в папку cef нашего клиентского скрипта accounts. Туда же ложим стили (style.css) и браузерные скрипты (script.js), которые мы напишем дальше.
Я немного модифицировал шаблон:
1. Добавил фоновую картинку
2. Расставил id для полей ввода, чтобы было удобнее работать с ними.
3. Убрал неиспользуемые стили
4. Добавил блок для вывода ошибок
5. Перевел на русский язык.

В итоге html файл выглядит так:
HTML:
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <link rel="stylesheet" href="package://accounts/cef/style.css">
</head>
<body>

<div class="login-page">
    <div class="form">
        <div id="error"></div>
        <form class="register-form" id="register">
            <input id="reg-login" type="text" placeholder="Логин"/>
            <input id="reg-password" type="password" placeholder="Пароль"/>
            <input id="reg-password-confirm" type="password" placeholder="Повторите Пароль"/>
            <button type="button" onclick="registerAttempt()">Регистрация</button>
            <p class="message">Уже зарегистрированы? <a href="#" onclick="showLogin()">Войти</a></p>
        </form>
        <form class="login-form" id="login">
            <input id="log-login" type="text" placeholder="Логин"/>
            <input id="log-password" type="password" placeholder="Пароль"/>
            <button type="button" onclick="loginAttempt()">Войти</button>
            <p class="message">Не зарегистрированы? <a href="#" onclick="showRegister()">создать аккаунт</a></p>
        </form>
    </div>
</div>


<script src="package://accounts/cef/script.js"></script>

</body>
</html>

Чтобы показать этот интерфейс игроку при входе на сервер, будем дергать с сервера на клиент событие showLoginDialog.
JavaScript:
mp.events.add("playerReady", player => {
    player.call('showLoginDialog');
});

На клиентской стороне добавим обработчик этого события
Код:
let loginBrowser;

mp.events.add('showLoginDialog', () => {
    loginBrowser = mp.browsers.new('package://accounts/cef/index.html'); // инициализируем браузер и отображаем страничку входа
    loginBrowser.execute("mp.invoke('focus', true)"); // показываем курсор
    mp.gui.chat.activate(false); // блокируем открытие чата при вводе текста в поля формы
});

Теперь при входе игрока на сервер ему будет показываться на форма входа.

form.jpg

Вернемся теперь к index.html и браузерной части. У нас есть две формы (register и login). Форма login отображается по-умолчанию, а register скрыта в стилях. Внизу каждой формы есть ссылка на другую и при помощи функций showLogin() и showRegister() мы будем переключаться между ними. Также для кнопок входа и регистрации добавлен вызов функций registerAttempt() и loginAttempt(), которые будут вызываться по событию onclick

В script.js добавим реализацию этих функций. С переключением между формами все просто:
JavaScript:
function showRegister(){
    document.getElementById('login').style.display = 'none';
    document.getElementById('register').style.display = 'block';
}

function showLogin(){
    document.getElementById('login').style.display = 'block';
    document.getElementById('register').style.display = 'none';
}

При отправке запроса на вход или регистрацию нам нужно считать содержимое полей формы, выполнить их базовые проверки и передать в клиент rage mp
JavaScript:
function registerAttempt(){
    // считываем содержимое полей
    const login = document.getElementById('reg-login').value;
    const password = document.getElementById('reg-password').value;
    const passwordConfirm = document.getElementById('reg-password-confirm').value;
    resetError();

    // Проверяем чтобы поля были заполнены, они были нужной длинны и пароли совпадали
    if(!login || login.length < 3){
        return showError('Введите логин');
    }

    if(!password || password.length < 6){
        return showError('Введите пароль');
    }

    if(password != passwordConfirm){
        return showError('Пароли не совпадают');
    }

    // Отправляем логин и пароль на клиент
    mp.trigger('registerAttempt', JSON.stringify({ login, password }) );
}

function loginAttempt(){
    const login = document.getElementById('log-login').value;
    const password = document.getElementById('log-password').value;
    resetError();

    if(!login || login.length < 3){
        return showError('Введите логин');
    }

    if(!password || password.length < 6){
        return showError('Введите пароль');
    }

    mp.trigger('loginAttempt', JSON.stringify({ login, password }) );
}

mp.trigger позволяем нам отправить из браузера на клиент только один дополнительный параметр. И это может быть только строка или число. Нам же нужно отправить два значения. Мы не можем отправить напрямую массив или объект, но мы можем преобразовать наш объект с логином и паролем в json строку JSON.stringify({ login, password }). И теперь эту строку мы легко передаем в одном аргументе.

Также в коде Вы наверное заметили функции связанные с выводом ошибок в форму на нашей страничке. Здесь все просто. У нас есть div блок с id error. Он находится выше наших форм и поэтому может показываться независимо от того на какой форме сейчас пользователь.

JavaScript:
function showError(message){
    const errorBlock = document.getElementById('error');
    errorBlock.innerText = message;
    errorBlock.style.display = 'block';
}

function resetError(){
    const errorBlock = document.getElementById('error');
    errorBlock.innerText = '';
    errorBlock.style.display = 'none';
}

В клиентской части добавим обработчики событий loginAttempt и registerAttempt, которые будут вызываться из браузерного скрипта.
JavaScript:
mp.events.add('loginAttempt', (data) => {
    mp.events.callRemote('onLoginAttempt', data);
});

mp.events.add('registerAttempt', (data) => {
    mp.events.callRemote('onRegisterAttempt', data);
});

Они максимально простые и просто передают данные с браузера дальше на сервер при помощи callRemote. Напоминаю что в качестве data у нас JSON строка с логином и паролем. В таком виде мы передаем ее дальше, поскольку callRemote также позволяет нам передавать только простые строки и числа.

На серверной стороне прежде чем обработать события onLoginAttempt и onRegisterAttempt нужно кое-что подготовить:
  1. Добавить пакет mysql и настроить подключение к серверу MySQL. Структура базы данных и само подключение будет таким же, как и в уроке по подключению MySQL. У нас будет 1 таблица accounts с тремя столбцами: id, login и password
  2. Добавить пакет bcrypt для генерации хэша паролей и его проверки.
JavaScript:
mp.events.add('onLoginAttempt', (player, data) => {
    data = JSON.parse(data); // преобразовуем данные из json в объект
    DB.query('SELECT * FROM accounts WHERE login = ? LIMIT 1', [data.login], function (error, results) { // ищем аккаунт по логину
        if(results.length == 0) return player.call('showAuthError', ['Неверный Логин и/или Пароль']); // если аккаунт с таким логином не найден, то возвращаем на клиент текст ошибки

        const passwordHash = results[0].password; // если же аккаунт есть, то берем его хеш пароля
        bcrypt.compare(data.password, passwordHash, function(err, isMatched) { // сравниваем хэши паролей из базы данных и того что указал пользователь
            if( isMatched ) return player.call('hideLoginDialog');  // если пароли не совпадают, значит пользователь авторизовался успешно
            player.call('showAuthError', ['Неверный Логин и/или Пароль']); // если же пароли не совпали, то опять таки возвращаем на клиент текст ошибки при помощи события showAuthError
        });
    });
});

На клиенте событие showAuthError просто показывает текст ошибки в форме.
JavaScript:
mp.events.add('showAuthError', (errorMessage) => {
    loginBrowser.execute(`showError("${errorMessage}")`);
});

А при успешном входе мы скрываем окно авторизации и считаем что игрок авторизовался
JavaScript:
mp.events.add('hideLoginDialog', () => {
    loginBrowser.execute("mp.invoke('focus', false)");
    loginBrowser.active = false;
    mp.gui.chat.activate(true);
});

Для регистрации на серверной стороне обработчик onRegisterAttempt будет немного сложнее. Прежде чем добавить аккаунт нам нужно проверить его на уникальность и его такой логин уже есть в базе данных, то выдавать ошибку.

JavaScript:
mp.events.add('onRegisterAttempt', (player, data) => {
    data = JSON.parse(data);

    DB.query('SELECT id FROM accounts WHERE login = ?', [data.login], function (error, results) {  // Проверяем уникальность логина
        if(results.length > 0) return player.call('showAuthError', ['Аккаунт с таким Логином уже существует']); // Если такой логин уже есть, то возвращаем ошибку

        bcrypt.hash(data.password, saltRounds, function(err, passwordHash) { // Создаем хэш пароля
            DB.query('INSERT INTO accounts SET login = ?, password = ?', [data.login, passwordHash], function (error, results) { // Добавляем аккаунт в базу данных
                player.call('hideLoginDialog'); // Скрываем окно авторизации
            });
        });
    });

});


Для тех кто захочет дальше ковырять эту форму, напишу парочку идей того, что можно улучшить и доработать:
1. Добавить защиту от перебора паролей. Кикать после 3 неправильных вводов.
2. Написать функцию isPlayerLoggedIn() которая будет возвращать true если игрок авторизовался и false если еще нет.
3. Добавить столбец position в таблицу accounts. Записывать туда позицию игрока при выходе с сервера.
4. Добавить возможность восстановить пароль. Для этого понадобиться добавить поле для email аккаунта и какой-то способ чтобы отправлять электронные письма с сервера.

Решение задач от пользователя @geneff
Задачи 1-3
Задача 4 (восстановление пароля)

Если при установке библиотеки bcrypt появляется ошибка 'undefined symbol: napi_module_register', то попробуйте это решение.
 

Вложения

Последнее редактирование:
Для отправки почты с сервера нам понадобиться дополнительный пакет (по аналогии с пакетом mysql). Почта отправляется по SMTP протоколу, поэтому нам нужен какой-то SMTP-клиент. Например, https://nodemailer.com/about/
Далее нам нужен почтовый сервис к которому мы будем подключаться и через него будет отправляться почта. Например, можно создать почту на gmail и там глянуть настройки SMTP, чтобы указать их в скрипте.

@geneff что касается алгоритма восстановления пароля. В задании не сказано как именно восстанавливать пароль, так что можно делать как угодно - главное достичь цели. Просто высылать новый пароль на почту будет неправильно. Тогда кто угодно, зная email аккаунта может менять пароль бесконечное количество раз :) Он конечно не будет знать новый пароль, но владельцу аккаунта будет весело :)

Второй вариант мне кажется более правильный. Генерируем код восстановления и отправляем его на почту. Этот код можно даже не хранить с базе, а только в памяти, поскольку когда игрок выйдет с сервера этот код уже будет недействителен. Так будет даже безопаснее, потенциальный хакер даже если и выманит этот код у игрока, то для него он будет бесполезен.
Ну и нужна простая форма в которую вводим код восстановления и два раза новый пароль.
В отдельный урок можно??
 
Почему бы нет;) Но я пока возьму паузу, может быть @geneff или кто-то еще решит задачку и поделится решением:) Было бы круто.
 
И так. У меня получилось.

1) Для начала устанавливаем nodemailer. Инструкция как его установить и как ним пользоватся тут.
2) Далее нам нужно немного изменить нашу форму и вот что у меня с этого получилось:

HTML:
HTML:
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <link rel="stylesheet" href="style.css">
</head>
<body>

<div class="login-page">
    <div class="form">
        <div id="error"></div>
        <form class="register-form" id="register">
            <input id="reg-login" type="text" placeholder="Логин"/>
            <input id="reg-password" type="password" placeholder="Пароль"/>
            <input id="reg-password-confirm" type="password" placeholder="Повторите Пароль"/>
            <input id="reg-mail" type="email" placeholder="Адрес электронной почты"/>
            <button type="button" onclick="registerAttempt()">Регистрация</button>
            <p class="message">Уже зарегистрированы? <a href="#" onclick="showLogin()">Войти</a></p>
        </form>
        <form class="login-form" id="login">
            <input id="log-login" type="text" placeholder="Логин"/>
            <input id="log-password" type="password" placeholder="Пароль"/>
            <button type="button" onclick="loginAttempt()">Войти</button>
            <p class="message">Не зарегистрированы? <a href="#" onclick="showRegister()">создать аккаунт</a></p>
            <p class="message">Забыли пароль? <a href="#" onclick="showResetPassword()">восстановить пароль</a></p>
        </form>
        <form class="reset-form" id="reset-password">
            <input id="mail" type="email" placeholder="Адрес электронной почты"/>
            <button type="button" onclick="isValidMail()">Восстановить</button>
            <p class="message"><a href="#" onclick="showLogin()">Назад</a></p>
        </form>
        <form class="reset-form" id="enter-code">
            <input id="code" type="text" placeholder="Введите код"/>
            <button type="button" onclick="isValidCode()">Ввести</button>
            <p class="message"><a href="#" onclick="showResetPassword()">Назад</a></p>
        </form>
        <form class="reset-form" id="enter-new-pass">
            <input id="new-pass" type="text" placeholder="Новый пароль"/>
            <input id="new-pass-confirm" type="text" placeholder="Повторите новый пароль"/>
            <button type="button" onclick="isValidPassword()">Восстановить</button>
            <p class="message"><a href="#" onclick="showResetPassword()">Назад</a></p>
        </form>
    </div>
</div>


<script src="script.js"></script>

</body>
</html>
CSS:
CSS:
.form .register-form, .form .reset-form {
    display: none;
}
3) Далее что-же изменилось в нашем script.js :
JavaScript:
const MIN_PASSWORD_LENGTH = 6; // константа минимальной длины пароля
const mail_regex = /[0-9a-z_-]+@[a-z]+\.[a-z]{2,5}/i; // регулярное выражение для проверки mail

let code; // тут будет записыватся наш код, который мы будем отправлять игроку на почту
let mail; // тут будет хранится мыло нашего игрока на которое мы будем отправлять сообщение

function showRegister(){
    resetError();
    document.getElementById('login').style.display = 'none';
    document.getElementById('reset-password').style.display = 'none';
    document.getElementById('register').style.display = 'block';
}

function showLogin(){
    resetError();
    document.getElementById('register').style.display = 'none';
    document.getElementById('reset-password').style.display = 'none';
    document.getElementById('enter-new-pass').style.display = 'none';
    document.getElementById('login').style.display = 'block';

}

function showResetPassword() { // показываем окно, где нужно ввести наше мыло
    resetError();
    document.getElementById('login').style.display = 'none';
    document.getElementById('register').style.display = 'none';
    document.getElementById('enter-code').style.display = 'none';
    document.getElementById('enter-new-pass').style.display = 'none';
    document.getElementById('reset-password').style.display = 'block';
}

function showEnterNewPassword() { // показываем окно где нужно указать новый пароль
    resetError();
    document.getElementById('reset-password').style.display = 'none';
    document.getElementById('enter-code').style.display = 'none';
    document.getElementById('enter-new-pass').style.display = 'block';
}

function showEnterCode() { // показываем окно где нужно указать код, который нам пришел на мыло
    resetError();
    document.getElementById('reset-password').style.display = 'none';
    document.getElementById('enter-code').style.display = 'block';
}

function isValidPassword() { // Проверка валидности пароля
    const pass = document.getElementById('new-pass').value;
    const passConfirm = document.getElementById('new-pass-confirm').value;

    if (pass.length < MIN_PASSWORD_LENGTH) {
        return showError('Слишком короткий пароль.');
    }

    if (pass !== passConfirm) {
        return showError('Пароли не совподают.');
    }
    mp.trigger('sendNewPasswordToTheClient', JSON.stringify({mail, pass})); // отправляем на клиент нашие данные
    showLogin(); // Показываем окно авторизации
}

function isValidCode() { // Проверяем совпадает ли введеный код с отправленым на почту
    const enterCode = parseInt(document.getElementById('code').value);
    if (enterCode !==  code) {
        return showError('Неверный код.');
    }
    showEnterNewPassword()
}

function isValidMail() { // Проверяем на валидность введеное мыло
    mail = document.getElementById('mail').value; // записываем в глобальную переменную наше мыло

    if (mail_regex.test(mail) === false) {
        return showError('не правильно');
    }
    code = Math.floor(Math.random() * (9999 - 1000) + 1000);
    mp.trigger('sendMailToTheClient', JSON.stringify({mail, code})); // передаем данные на клиент

    showEnterCode();
}

function loginAttempt(){
    const login = document.getElementById('log-login').value;
    const password = document.getElementById('log-password').value;
    resetError();

    if(!login || login.length < 3){
        return showError('Введите Логин');
    }

    if(!password || password.length < MIN_PASSWORD_LENGTH){
        return showError('Введите Пароль');
    }

    mp.trigger('loginAttempt', JSON.stringify({login, password}));
}

function registerAttempt(){
    const login = document.getElementById('reg-login').value;
    const password = document.getElementById('reg-password').value;
    const passwordConfirm = document.getElementById('reg-password-confirm').value;
    const mail = document.getElementById('reg-mail').value;

    resetError();

    if(login.length < 3){
        return showError('Введите Логин');
    }

    if(password.length < MIN_PASSWORD_LENGTH){
        return showError('Введите Пароль');
    }

    if(password !== passwordConfirm){
        return showError('Пароли не совпадают');
    }

    if(mail_regex.test(mail) === false) {
        return showError('Не правильно введен адрес электронной почты.');
    }

    mp.trigger('registerAttempt', JSON.stringify({login, password, mail}) );
}

function showError(message){
    const errorBlock = document.getElementById('error');
    errorBlock.innerText = message;
    errorBlock.style.display = 'block';
}

function resetError(){
    const errorBlock = document.getElementById('error');
    errorBlock.innerText = 'message';
    errorBlock.style.display = 'none';
}
4) Обработка ивентов на клиенте:
JavaScript:
mp.events.add('sendMailToTheClient', data => { // отправляем данные на сервер
    mp.events.callRemote('sendMailToTheServer', data);
})

mp.events.add('sendNewPasswordToTheClient', data => { // отправляем данные на сервер
    mp.events.callRemote('changePlayerPassword', data);
})
5) После установки nodemailer, для удобности я создал отдельный файл mailSender.js (packages/accounts/mailSender.js) :
JavaScript:
const nodemailer = require('nodemailer');

// Работает только для почт mail.ru
const transporter = nodemailer.createTransport({
    host: "smtp.mail.ru",
    port: 465,
    secure: true, // true for 465, false for other ports
    auth: {
        user: "наша почта", // generated ethereal user
        pass: "пароль от нашей почты", // generated ethereal password
    },
},
    {
        from: "Rage Script <наша почта>"
    });


const sendMail = async message => {
    try {
        await transporter.sendMail(message);
        console.log('Письмо успешно отправлено');
    } catch (e) {
        console.log('Не удалось отправить письмо', e);
    }

}
module.exports = sendMail;
Далее в index.js добавляем:
JavaScript:
const sendMail = require('./mailSender');
mp.events.add('sendMailToTheServer', async (player, data) => {
    data = JSON.parse(data);

    const message = {
        to: data.mail,
        subject: 'Восстановление пароля от аккаунта',
        text: `Ваш код для восстановления пароля: ${data.code}`
    };
    await sendMail(message);
})

mp.events.add('changePlayerPassword', (player, data) => {
    data = JSON.parse(data);
    DB.query('UPDATE accounts SET password = ? WHERE mail = ? LIMIT 1', [data.pass, data.mail], (err) => {
        if (err)
            console.log('Не удалось изменить пароль игрока');
        else
            console.log('Пароль игрока успешно изменен');
    });
})
6) И последнее что мы должны изменить, это добавить в базу данных колонку mail. А также при регистрации в нашем index.js немного изменить запрос:
JavaScript:
'INSERT INTO accounts SET login = ?, password = ?, mail = ?', [data.login, data.password, data.mail]

Screenshot_1.png1607872320123.png1607872389239.png1607872366830.png1607872418680.png1607872429612.png
Ну вот впринципе и все :)
 
Последнее редактирование:
Да ты крут :cool: Вижу что ты далеко не newcomer :) Сделал хорошо, на пятерку!

Есть 2 рекомендации как можно сделать лучше. Потяное дело что это учебный скрипт и тут еще 100500 вариантов что можно добавить, но напишу о том, что бросилось в глаза.

Клиентская часть часто позволяет нам выполнять вычисления которые связанные с локальным игроком. Это удобно + позволяет разгрузить сервер и в большинстве случаев это более разумно. Но нельзя забывать про безопасность и какие-то потенциально критичные вычисления лучше все равно выполнять на сервере. Всегда помни что данные на клиенте потенциально не в безопасности и могут быть изменены или подслушаны. Исходя из этого предложил бы следующее:
1. Код восстановления генерировать и хранить только на сервере. Допустим сейчас будет сложно внедриться на клиент и генерировать свои код, но вот подслушать трафик между клиентом и сервером уже реальнее. Возможно там конечно данные передаются в зашифрованном виде, я даже не знаю. Но если я делаю безопасный алгоритм, по мне без разницы.
2. Email запоминал бы на сервере. В этом случае нам допустим неважно подслушивать его, но если мы сможем на втором этапе его подменить с клиента, то можем сменить пароль для любого аккаунта.

Это не то, чтобы улучшения. Просто хочу передать общую идею, что клиенту нельзя безоговорочно доверять;)

Молодец что разобрался с отправкой писем. В этом задании это было наверное самое сложное.
Мне понравилось что sendMail вынес в отдельный файл и теперь его можно легко повторно использовать в других местах мода. Спасибо что подробно описал решение, желающие смогут легко его разобрать и повторить.

Не возражаешь если я добавлю ссылки на решения в первое сообщение?
 
Клиентская часть часто позволяет нам выполнять вычисления которые связанные с локальным игроком. Это удобно + позволяет разгрузить сервер и в большинстве случаев это более разумно. Но нельзя забывать про безопасность и какие-то потенциально критичные вычисления лучше все равно выполнять на сервере. Всегда помни что данные на клиенте потенциально не в безопасности и могут быть изменены или подслушаны. Исходя из этого предложил бы следующее:
1. Код восстановления генерировать и хранить только на сервере. Допустим сейчас будет сложно внедриться на клиент и генерировать свои код, но вот подслушать трафик между клиентом и сервером уже реальнее. Возможно там конечно данные передаются в зашифрованном виде, я даже не знаю. Но если я делаю безопасный алгоритм, по мне без разницы.
2. Email запоминал бы на сервере. В этом случае нам допустим неважно подслушивать его, но если мы сможем на втором этапе его подменить с клиента, то можем сменить пароль для любого аккаунта.
Я думал об этом, но просто я не знаю, как мне с клиента передать данные в наш CEF для проверки. Если знаете, подскажите мне, пожалуйста)
 
О чем речь? Какую проверку?
Ну вот я сгенерировал код на сервере и отправил его на почту, тепер мне же как-то нужно проверять введенный код и отправленный, то-есть, мне нужно отправить наш сгенерированый код на клиент, а потом с клиента в наш браузер, для того чтобы сравнить эти два кода.

Хотяяяяяяя, похоже я не много намудрил и можно введенный код в нашем CEF отправить на сервер и уже на самом сервере проверять...
 
Все верно. Мы же не передаем хеш пароля на клиент при авторизации ;)
 
Назад
Верх