Задача авторизации возникает практически в каждом Node.js проекте, однако, чтобы ее правильно настроить, необходимо подключить большое количество модулей и собрать кучу информации из разных источников.
В этой статье я опишу полноценное решение по авторизации на основе JSON Web Token (JWT) для Node.js и Koa с хранением хэшей паролей в MongoDB. От читателя ожидаются базовые знания Node.js и принципов работы с MongoDB через Mongoose.
Несколько слов, о чем конкретно пойдет речь и почему.
Почему Коа. Не смотря на значительно большую популярность фреймворка Express, Koa предоставляет возможность писать приложения используя современный синтаксис async/await. Использование async/await вместо callback’ов является достаточно большим стимулом, чтобы присмотреться к этому фреймворку.
Почему JWT. Подход к авторизации с помощью сессий можно уже назвать устаревшим, так как он не позволяет использовать его в мобильных приложениях и там, где нет поддержки cookies. Также проблемы с сессиями могут возникнуть в кластерных системах. JWT авторизация не имеет этих недостатков, и обладает еще рядом дополнительных преимуществ. Более подробно про JWT можно прочитать тут.
В статье будет рассмотрено полноценное решение по авторизации с использованием:
- passport.js. Де-факто стандарт для работы с авторизацией в Node.js проектах
- хешированием паролей и хранением хэшей в базе MongoDB
- аутентификацией для REST API
- аутентификацией для socket.io, что является обычно более сложной темой, чем п.3
Чтобы сохранить образовательную ценность статьи в коде не будет расширенных проверок на ошибки и исключения, которые часто делают код менее понятным. Поэтому перед использованием примеров кода в продакшене, надо поработать над обработкой ошибок и контролем входных данных от клиента.
Итак, начнем
1. Подключаем Koa. В отличие от Express, Koa является более легким фреймворком и поэтому, обычно, используется с рядом дополнительных модулей.
const Koa = require('koa'); // ядро
const Router = require('koa-router'); // маршрутизация
const bodyParser = require('koa-bodyparser'); // парсер для POST запросов
const serve = require('koa-static'); // модуль, который отдает статические файлы типа index.html из заданной директории
const logger = require('koa-logger'); // опциональный модуль для логов сетевых запросов. Полезен при разработке.
const app = new Koa();
const router = new Router();
app.use(serve('public'));
app.use(logger());
app.use(bodyParser());
2. Подключаем Passport.js. Passport.js позволяет гибко настраивать авторизацию, используя разные механизмы, которые называются Стратегиями (локальная, социальные сети д.р.). В настоящий момент библиотека насчитывает более 300 вариантов стратегий.
const passport = require('koa-passport'); //реализация passport для Koa
const LocalStrategy = require('passport-local'); //локальная стратегия авторизации
const JwtStrategy = require('passport-jwt').Strategy; // авторизация через JWT
const ExtractJwt = require('passport-jwt').ExtractJwt; // авторизация через JWT
app.use(passport.initialize()); // сначала passport
app.use(router.routes()); // потом маршруты
const server = app.listen(3000);// запускаем сервер на порту 3000
3. Подключаем работу с JWT. В двух словах JWT — это просто JSON в котором может храниться, например, email пользователя. Этот JSON подписывается секретным ключом, что не позволяет этот email изменить, хотя позволяет его прочитать.
Таким образом, получая с клиента JWT вы уверены, что к вам пришел именно тот пользователь, за которого он себя выдает (при условии, что его JWT не был кем-то украден, но это уже совсем другая история).
const jwtsecret = "mysecretkey"; // ключ для подписи JWT
const jwt = require('jsonwebtoken'); // аутентификация по JWT для hhtp
const socketioJwt = require('socketio-jwt'); // аутентификация по JWT для socket.io
4. Подключаем socket.io. В двух словах socket.io — это модуль для работы приложений, которые реагируют на изменения происходящие на сервере, например его можно использовать для чата. Если сервер и браузер поддерживают протокол WebSockets, то socket.io будет использовав его, иначе он поищет другие механизмы реализации двустороннего общения браузера с сервером.
const socketIO = require('socket.io');
5. Подключаем MongoDB для хранения объектов пользователей.
const mongoose = require('mongoose'); // стандартная прослойка для работы с MongoDB
const crypto = require('crypto'); // модуль node.js для выполнения различных шифровальных операций, в т.ч. для создания хэшей.
Теперь запустим все это вместе
Объект пользователя (user) будет состоять из его имени, e-mail и хэша пароля.
Для превращения пароля, получаемого из POST запроса в хэш, который будет храниться в базе применяется концепция виртуальных полей. Виртуальное поле — это поле, которое есть в модели Mongoose, но которого нет в базе MongoDB.
mongoose.Promise = Promise; // Просим Mongoose использовать стандартные Промисы
mongoose.set('debug', true); // Просим Mongoose писать все запросы к базе в консоль. Удобно для отладки кода
mongoose.connect('mongodb://localhost/test'); // Подключаемся к базе test на локальной машине. Если базы нет, она будет создана автоматически.
Создаем схему и модель для Пользователя:
const userSchema = new mongoose.Schema({
displayName: String,
email: {
type: String,
required: 'Укажите e-mail',
unique: 'Такой e-mail уже существует'
},
passwordHash: String,
salt: String,
}, {
timestamps: true
});
userSchema.virtual('password')
.set(function (password) {
this._plainPassword = password;
if (password) {
this.salt = crypto.randomBytes(128).toString('base64');
this.passwordHash = crypto.pbkdf2Sync(password, this.salt, 1, 128, 'sha1');
} else {
this.salt = undefined;
this.passwordHash = undefined;
}
})
.get(function () {
return this._plainPassword;
});
userSchema.methods.checkPassword = function (password) {
if (!password) return false;
if (!this.passwordHash) return false;
return crypto.pbkdf2Sync(password, this.salt, 1, 128, 'sha1') == this.passwordHash;
};
const User = mongoose.model('User', userSchema);
Для более глубокого понимания механизма работы с хэшами паролей можно почитать про команду pbkdf2Sync в доке по Node.js
Настраиваем работу с Passport.js
Процесс авторизации пользователя выглядит следующим образом:
Шаг 1. Новый пользователь регистрируется, и создается запись о нем в базе MongoDB.
Шаг 2. Пользователь логинится с паролем на сайте и при успешном вводе логина и пароля получает JWT.
Шаг3. Пользователь заходит на произвольный ресурс, отсылает свой JWT, по которому и авторизуется уже без ввода пароля.
Механизм настройки Passport.js состоит из двух этапов:
Этап 1. Настройка Стратегий. Стратегия при успешной авторизации возвращает объект user, описанный ранее в схеме userSchema.
Этап 2. Использование полученного на этапе 1 объекта user для последующих действий, например, создания для него JWT.
Этап 1
Настраиваем Passport Local Strategy. Более подробно, как работает стратегия можно прочитать на тут.
passport.use(new LocalStrategy({
usernameField: 'email',
passwordField: 'password',
session: false
},
function (email, password, done) {
User.findOne({email}, (err, user) => {
if (err) {
return done(err);
}
if (!user || !user.checkPassword(password)) {
return done(null, false, {message: 'Нет такого пользователя или пароль неверен.'});
}
return done(null, user);
});
}
)
);
Настраиваем Passport JWT Strategy. Более подробно, как работает стратегия можно прочитать на тут.
// Ждем JWT в Header
const jwtOptions = {
jwtFromRequest: ExtractJwt.fromAuthHeader(),
secretOrKey: jwtsecret
};
passport.use(new JwtStrategy(jwtOptions, function (payload, done) {
User.findById(payload.id, (err, user) => {
if (err) {
return done(err)
}
if (user) {
done(null, user)
} else {
done(null, false)
}
})
})
);
Этап 2
Мы создадим REST API, который будет работать с объектом user.
API будет состоять из трех endpoints, соответствующих трем Шагам процесса авторизации, описанному выше.
Post запрос на /user – создает нового пользователя. Обычно этот API вызывается при регистрации нового пользователя. В теле запроса мы ожидаем JSON с именем, почтой и паролем пользователя.
router.post('/user', async(ctx, next) => {
try {
ctx.body = await User.create(ctx.request.body);
}
catch (err) {
ctx.status = 400;
ctx.body = err;
}
});
Post запрос на /login создает JWT для пользоваться. В теле запроса мы ожидаем получить JSON в котором будет почта и пароль пользователя. В продакшене логично JWT выдавать также и при регистрации пользователя.
router.post('/login', async(ctx, next) => {
await passport.authenticate('local', function (err, user) {
if (user == false) {
ctx.body = "Login failed";
} else {
//--payload - информация которую мы храним в токене и можем из него получать
const payload = {
id: user.id,
displayName: user.displayName,
email: user.email
};
const token = jwt.sign(payload, jwtsecret); //здесь создается JWT
ctx.body = {user: user.displayName, token: 'JWT ' + token};
}
})(ctx, next);
});
GET запрос на /custom проверяет наличие валидного JWT.
router.get('/custom', async(ctx, next) => {
await passport.authenticate('jwt', function (err, user) {
if (user) {
ctx.body = "hello " + user.displayName;
} else {
ctx.body = "No such user";
console.log("err", err)
}
} )(ctx, next)
});
Теперь сделаем финальный аккорд по настройке авторизации для socket.io. Проблема тут в том, что протокол WebSockets работает поверх tcp, а не http и механизмы REST API к нему не применимы. К счастью, для него есть модуль socketio-jwt, который позволяет достаточно лаконично описать авторизацию через JWT.
let io = socketIO(server);
io.on('connection', socketioJwt.authorize({
secret: jwtsecret,
timeout: 15000
})).on('authenticated', function (socket) {
console.log('Это мое имя из токена: ' + socket.decoded_token.displayName);
socket.on("clientEvent", (data) => {
console.log(data);
})
});
Более подробно про авторизацию через JWT для socket.io можно почитать тут.
Заключение
Используя код выше вы можете построить рабочее Node.js приложение, используя современный подход к авторизации. Разумеетсяя в продакшене надо будет добавить ряд проверок, которые обычно стандартны для такого рода приложений.
Полную версию кода с описание того, как его протестировать можно посмотреть в GitHub.
Комментарии (16)
RidgeA
16.03.2017 12:08>> нет поддержки cookies
нет поддержки http заголовков? Это скорее проблемы имплементации http клиента…
david_mz
16.03.2017 16:02Я правильно понимаю (по приведённому коду), что однажды выданный токен будет работать вечно и отозвать его невозможно?
Razaz
16.03.2017 17:25Используйте OAuth2 + OpenID Connect. В первом комменте очень полезная ссылка. Как раз про некорректное использование.
slavaLu
17.03.2017 18:29Можно в токене указывать период валидности. Так обычно все и делают, просто как было сказано в начале статьи, я старался не загромождать код.
Кроме того, ничто не мешает нужного юзера добавить в группу, для которой не будет работать авторизация по токену, таким образом отправив его снова на логин/пароль. После этого убрать его из этой группы.
3luyka
17.03.2017 18:29в зависимости от Вашей реализации, можно установить свойство exp для токена и проверять его :)
Veha
17.03.2017 18:29Для токена можно установить время жизни, после которого он будет не валиден:
jwt.sign({ data: 'foobar' }, 'secret', { expiresIn: '1h' });
DreaMinder
17.03.2017 18:30+1буквально неделю назад поднимал аутентификацию на таком же стеке, лепил туда так же паспорт… потом одумался, выбросил его и все отлично работает. До сих пор не пойму, какую функцию он здесь выполняет. Декодирование токена отлично делает koa-jwt, а проверка существования пользователя при запросе — 2 строки кода запроса в БД (с асинк\эвейтом).
Его использование оправдано разве что если нужна аутентификация по соц сетям… но разве она совместима с jwt?slavaLu
17.03.2017 18:38+1Я согласен, что в случае с токеном пользы от паспорта может быть меньше, чем в сессиях. Но однажды освоив паспорт вы будет любую авторизацию писать легко и не принужденно.
А если вы перейдете с Коа на hapi, придется искать модуль для декодирования jwt для hapi? если паспорт не вызывает отторжения ( а при первом знакомстве такое может произойти) я бы советовал использовать его.
yarkov
17.03.2017 18:43А у меня в личном кабинете сделан функционал, который показывает все текущие сессии, с информацией об IP и устройстве, с которого она открыта. Плюс позволяет оборвать любую открытую сессию с любого устройства.
В случае с JWT мне нужно самому писать замену сессиям? Я правильно понимаю?slavaLu
17.03.2017 18:46если Вам действительно надо смотреть сессии и иметь возможность их обрывать, то наверно Вам лучше продолжать использовать сессии. Задача токена, не улучшить управляемость доступом к сайту, а упростить ее и сделать более универсальной.
Aspos
Для лучшего усвоения вот добавка:
критика JWT
Razaz
Тут есть небольшие подвижки. https://tools.ietf.org/html/draft-ietf-cose-msg-24
slavaLu
Мне кажется проблемы более надуманы. Да, для супер надежных финансовых систем может быть, но что мешает забанить злого юзера вместо того. чтобы что-то делать с его токеном?
Учитывая использования выражений типа «Bad Standard That Everyone Should Avoid» я бы не принимал эту статью и те статьи, куда она ведет очень близко к сердцу.