Аутентифікація з логіном та паролем
Спочатку давайте розберемося з термінологією. Аутентифікація та авторизація, що це? Ці два терміни багато хто використовує як взаємозамінні, проте це не Зовсім так. Аутентифікація відноситься до перевірки автентичності користувача: що він той, за кого себе видає. Авторизація відноситься до визначення того, до чого користувач може отримати доступ до вашого веб-додатку. Типовий приклад звичайні користувачі та адміністратор який теж авторизований, але має доступ не лише до свого облікового запису, але й до записів інших користувачів. Логічно стає зрозуміло, що спочатку виконується аутентифікація, а потім встановлюється авторизація.
У цьому розділі ми розробимо програму Node.js та використуємо популярне проміжне програмне забезпечення автентифікації - Passport. Спочатку ми розберемо класичний спосіб - використання пароля та імені користувача. Це так звана локальна стратегія.
Як проміжне програмне забезпечення, Passport легко налаштовується в будь-якому веб-додатку на основі Express так само, як ми б налаштовували будь-яке інше проміжне ПЗ Express таке як: body парсер, робота з cookie, обробка сеансу тощо.
Стратегії аутентифікації
Passport надає на вибір понад 500 механізмів аутентифікації. Починаючи з простих логін-пароль, до використання провайдерів аутентифікації соціальних мереж.
Всі ці стратегії незалежні одна від одної та упаковані як окремі вузлові модулі, які не включаються за умовчанням під час встановлення проміжного ПЗ Passport:
npm install passport
Дані наших користувачів ми зберігатимемо у базі даних MongoDB. Для використання локальної стратегії аутентифікації нам необхідно встановити необхідний модуль:
npm install passport-local
Налаштування програми
Ми будемо використовувати наступні залежності для нашого проекту, які потрібно встановити:
npm i bcryptjs connect-flash dotenv ejs express express-session mongoose passport passport-local
Створення моделі Mongoose
Оскільки ми зберігатимемо дані користувача в MongoDB, то будемо використовувати Mongoose у якості ODM.
Модель користувача в Mongoose буде наступного вигляду і зберігатимемо її у файлі
schemas/user.js
нашої програми.
const mongoose = require('mongoose');
const bCrypt = require('bcryptjs');
const Schema = mongoose.Schema;
const userSchema = new Schema({
username: String,
email: {
type: String,
required: [true, 'Email required'],
unique: true,
},
password: {
type: String,
required: [true, 'Password required'],
},
});
userSchema.methods.setPassword = function (password) {
this.password = bCrypt.hashSync(password, bCrypt.genSaltSync(6));
};
userSchema.methods.validPassword = function (password) {
return bCrypt.compareSync(password, this.password);
};
const User = mongoose.model('user', userSchema);
module.exports = User;
Щоб зберегти користувача в базі даних, а потім порівняти пароль, який він
вводить нам необхідно зашифрувати пароль, так як небезпечно зберігати паролі
без шифрування у базі даних. Відповідно до вікіпедії bcrypt
— адаптивна
криптографічна хеш-функція формування ключа, що використовується для захищеного
зберігання паролів. Розробники: Нільс Провос та David Mazières. Функція заснована
на шифрі Blowfish, вперше представлена на USENIX у 1999 році. Для захисту від
атак за допомогою райдужних таблиць bcrypt використовує сіль (salt); Крім того,
функція є адаптивною, час її роботи легко налаштовується та її можна
уповільнити, щоб ускладнити атаку перебором.
У npm репозиторії існують два популярні пакети для хешування паролів
bcrypt
та bcryptjs
. Відмінність у тому, що пакет bcrypt
працює тільки з LTS
версіями та частіше використовується на продакшені, тому цілком можливо, що у вас
стоїть остання версія node.js для навчання, і він просто не буде працювати.
Тому ми використовуємо bcryptjs
без шкоди безпеки нашої програми.
У нас є функція setPassword
яка буде шифрувати пароль, та функція
validPassword
, яка перевірятиме валідність нашого пароля.
Для підключення ми будемо використовувати хмарну базу даних Mongo Atlas. URI
(уніфікований ідентифікатор ресурсу) для підключення до бази даних ми будемо
зберігати у файлі змінних оточення .env
у змінній DB_HOST
.
Тепер використовуючи цю конфігурацію у головному файлі server.js
ми підключаємося до
неї, використовуючи Mongoose APIs:
const mongoose = require('mongoose');
require('dotenv').config();
mongoose.Promise = global.Promise;
mongoose.connect(process.env.DB_HOST, {
useNewUrlParser: true,
useCreateIndex: true,
useUnifiedTopology: true,
});
Налаштування Passport
Passport надає лише механізм для автентифікації користувача,
відповідальність за реалізацію залишається на самому сеансі обробки, так що для
цього нам треба використати express-session
. І значить у файл server.js
нам
треба вставити наступний код перед роутингом:
app.use(
session({
secret: 'secret-word',
key: 'session-key',
cookie: {
path: '/',
httpOnly: true,
maxAge: null,
},
saveUninitialized: false,
resave: false,
}),
);
require('./config/config-passport');
app.use(passport.initialize());
app.use(passport.session());
Це необхідно зробити, щоб сеанс нашого користувача носив постійний характер.
Серіалізація та десеріалізація екземплярів користувача
Passport також повинен серіалізувати та десеріалізувати екземпляр користувача з
сховища сеансів для забезпечення підтримки сеансів входу в систему
так, щоб кожен наступний запит не містив облікові дані користувача.
Для реалізації цієї мети є два методи serializeUser
та deserializeUser
:
passport.serializeUser((user, done) => {
done(null, user.id);
});
passport.deserializeUser((id, done) => {
User.findById(id, (err, user) => {
done(err, user);
});
});
Ми їх винесемо в окремий каталог та в окремий файл config-passport.js
Всередині
цих функцій ми використовуємо функцію зворотнього виклику done
. Ідентифікатор
користувача, який ми вказуємо як другий аргумент цієї функції
зберігається в сеансі і пізніше використовується для отримання всього об'єкта через
deserializeUser
функцію. Функція serializeUser
визначає, які дані
об'єкта користувача повинні зберігатися в сеансі. Результат методу
serializeUser
прикріплюється до сеансу як req.session.passport.user
. В нашому
випадку в req.session.passport.user
зберігатиметься унікальний ідентифікатор
користувача.
Грубо кажучи функція serializeUser
записує ідентифікатор користувача в
сесію, а deserializeUser
дістає цей ідентифікатор, витягує об'єкт
користувача з бази та зберігає користувача на запит як req.user
, звідки ми
і отримуємо до нього доступ.
Використання стратегії Passport
Тепер визначимо стратегію Passport для обробки авторизації. Ми використовуємо
проміжне ПЗ connect-flash
для відображення миттєвих повідомлень
користувачеві, якщо користувач зробив помилку.
Стратегія авторизації входу до системи виглядає так:
passport.use(
new LocalStrategy({ usernameField: 'email' }, (email, password, done) => {
User.findOne({ email })
.then(user => {
if (!user) {
return done(null, false);
}
if (!user.validPassword(password)) {
return done(null, false);
}
return done(null, user);
})
.catch(err => done(err));
}),
);
Перший параметр для passport.use()
це ім'я стратегії, яке буде
використовуватися для ідентифікації цієї стратегії при її подальшому застосуванні. Ми
опускаємо цей параметр і він використовуватиме значення за замовчуванням як
'local'. Другий параметр - це тип стратегії, яку ви хочете створити. Тут ми
використовуємо username-password
або LocalStrategy
. Слід зазначити, що за
замовчуванням LocalStrategy
очікує знайти облікові дані користувача у параметрах
req.body
як username
і password
, однак вона також дозволяє нам
використовувати будь-які інші назви. Тому ми через параметр
usernameField
змінюємо значення username
на email
. Змінна конфігурації
passReqToCallback
дозволяє нам отримати доступ до request
об'єкту у зворотному
виклику, тим самим дозволяючи нам використовувати будь-який параметр, пов'язаний з
запитом, але в нашому прикладі ми її не використовуємо.
Далі, ми використовуємо Mongoose API, щоб знайти користувача і перевірити чи
є він дійсним чи ні. Останній параметр у нашому зворотному виклику –
done
. Він позначає метод, використовуючи який ми сигналізуємо про успішність
або невдачі модуля Passport. Щоб вказати на невдачу, треба щоб або перший
параметр містив помилку, або другий параметр дорівнював false
. Щоб вказати
на успіх, перший параметр має бути null
, а другий повинен мати значення
true
, у разі чого він буде доступний у request
об'єкті. У нашому випадку ми
туди поміщаємо об'єкт користувача.
Щоб повніше представляти нашу програму, а не бачити тільки фрагменти коду ви можете спостерігати його в дії повністю:
Відкрийте програму в новому вікні, а не у кадрі. Інакше проміжне ПЗ connect-flash може не працювати. Це наступна знизу кнопка – праворуч від кнопки 'View Source'
Створення роутів
Тепер ми визначаємо наші шляхи для застосування у модулі routes/index.js
const express = require('express');
const router = express.Router();
const passport = require('passport');
const User = require('../schemas/user');
const isLoggedIn = (req, res, next) => {
if (req.isAuthenticated()) {
return next();
}
req.flash('message', 'Авторизуйтесь');
res.redirect('/');
};
router.get('/', (req, res, next) => {
res.render('index', { message: req.flash('message') });
});
router.post('/login', (req, res, next) => {
passport.authenticate('local', (err, user) => {
if (err) {
return next(err);
}
if (!user) {
req.flash('message', 'Вкажіть правильний логін та пароль!');
return res.redirect('/');
}
req.logIn(user, function (err) {
if (err) {
return next(err);
}
return res.redirect('/profile');
});
})(req, res, next);
});
router.get('/registration', (req, res, next) => {
res.render('registration', { message: req.flash('message') });
});
router.post('/registration', async (req, res, next) => {
const { username, email, password } = req.body;
try {
//створюємо екземпляр користувача та вказуємо введені дані
const user = await User.findOne({ email });
//якщо такий користувач вже є – повідомляємо про це
if (user) {
req.flash('message', 'Користувач із таким Email вже існує');
return res.redirect('/');
}
const newUser = new User({ username, email });
newUser.setPassword(password);
//якщо ні - додаємо користувача до бази
await newUser.save();
req.flash('message', 'Ви успішно зареєструвалися');
res.redirect('/');
} catch (e) {
next(e);
}
});
router.get('/profile', isLoggedIn, (req, res, next) => {
console.log(req.session.passport);
const { username, email } = req.user;
res.render('profile', { username, email });
});
router.get('/logout', function (req, res) {
req.logout();
res.redirect('/');
});
module.exports = router;
Найважливіша частина наведеного вище фрагмента коду – це використання
passport.authenticate()
для роута /login
, щоб делегувати аутентифікацію
для local
стратегії, коли HTTP POST
метод виконується для цього шляху.
Для реєстрації користувача ми використовуємо роутер на маршруті /registration
.
Тут ми знову використовуємо Mongoose API, щоб визначити, чи існує вже
користувач із зазначеним email
або ні. Якщо ні, тоді створюємо нового
користувача та зберігаємо про нього інформацію в Mongo. Інакше ми повернемо
помилку за допомогою миттєвих повідомлень. Зверніть увагу, що ми використовуємо
bcryptjs
через функцію newUser.setPassword(password)
для створення хешу
пароля перед збереженням.
Створення шаблонів EJS
Наш додаток використовує наступні три шаблони:
index.ejs
— містить сторінку входу в систему, що містить форму входуregistration.ejs
— містить форму для реєстрації нового облікового записуprofile.ejs
— це наша секретна сторінка, куди потрапити ми можемо лише залогінившисьerror.ejs
— використовується для виведення помилок
Для стилізації наших шаблонів ми частково використовуємо Bootstrap.
Зверніть увагу, що у шаблонах index.ejs
та registration.ejs
ми використовуємо
такий шматок коду
<% if (message) { %>
<h4><%= message %></h4>
<% } %>
Він використовується, щоб виводити миттєві повідомлення для користувача у випадку помилок. Спробуйте створити двох користувачів з однаковим email та побачите результат.
Здійснення функції виходу із системи
Passport додає певні властивості та методи до об'єктів запиту та відповіді.
І, щоб виконати розлогування користувача ми використовуємо, метод
request.logout()
він робить недійсним сеанс користувача.
router.get('/logout', (req, res) => {
req.logout();
res.redirect('/');
});
Захист шляхів. Авторизація
Але головне для нас, що Passport дає можливість захищати доступ до шляху,
який має бути недоступним для анонімного користувача. Це означає, що
якщо будь-який користувач спробує отримати доступ до /profile
без
аутентифікації в додатку, він буде перенаправлений на домашню сторінку, з
пропозицією залогінитись
router.get('/profile', isLoggedIn, (req, res, next) => {
const { username, email } = req.user;
res.render('profile', { username, email });
});
Як бачимо перш ніж виконається обробник роуту /profile
виконується функція
проміжної обробки isLoggedIn
const isLoggedIn = (req, res, next) => {
if (req.isAuthenticated()) {
return next();
}
req.flash('message', 'Авторизуйтесь');
res.redirect('/');
};
Ця функція використовує ще один метод Passport як isAuthenticated
, котрий
приймає значення true
якщо користувач пройшов аутентифікацію.
Ми розглянули базовий приклад аутентифікації за допомогою логіну та паролю, де в якості логіну виступає email користувача