Июнь 2016

Объем 31 Номер 6

Работающий программист - MEAN: Passport

Тэд Ньюард | Июнь 2016

Тэд НьюардС возвращением, дорогие «министы».

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

Хотя всегда есть соблазн «скрутить что-то свое», для сообщества Node это уже уровень 2010 года! Правильный выбор при столкновении с любыми видами таких дилемм — всегда обращаться к npm, и в данном случае бесспорный лидер среди систем аутентификации — библиотека Node.js с названием Passport.

Passport

К этому моменту вы должны прекрасно понимать, каковы первые шаги в использовании библиотеки Passport: найти npm-пакет с нужным названием и воспользоваться командой npm install. Название npm-пакета можно обнаружить либо поиском по онлайновому реестру npm, либо на начальной странице Passport. Однако при первом посещении PassportJS.org вы узнаете две интересных вещи: во-первых, в противоположность веб-страницам любых других пакетов для Node.js tкоманда npm install на начальной странице отсутствует, а во-вторых, Passport имеет концепцию стратегий, и это важно.

Причина этого проста. Когда вы говорите: «Пора проверить подлинность удостоверений пользователя», это на самом деле весьма туманное заявление. Дело не только в том, что для аутентификации могу использоваться самые разнообразные удостоверения, — существует еще и тысяча или более различных хранилищ удостоверений (а-ля серверы), в которых мог бы аутентифицироваться пользователь. Passport старается быть решением для любого типа аутентификации в любом хранилище удостоверений — Facebook, LinkedIn, Google или вашей локальной базе данных — и использует самые разнообразные удостоверения, от имени/пароля пользователя и JSON Web Tokens до HTTP-заголовков Bearer и практически всего остального, что вы могли бы вообразить.

Это означает, что Passport — не просто один пакет; существует базовый пакет passport и набор стратегий (на момент написания этой статьи их было 307) для того, как Passport должен реально выполнять работу по аутентификации. Выбор стратегии (или стратегий — я вскоре перейду к ним) определяет, какой именно пакет вам нужен, а это в свою очередь определяет, что вы будете устанавливать. (Но, во имя правдивости рекламы, Passport действительно определяет базовый пакет passport, который будет использоваться всеми выбранными стратегиями, так что вы можете перейти к делу, выполнив команду npm install --save passport еще до того, как я перейду к деталям стратегий.)

Локальная стратегия

Несомненно, наиболее распространенной стратегией (особенно для систем, созданных применительно к внутренним базам данных пользователей/хранилищ удостоверений) является локальная. Это классика в стиле «клиент посылает имя и пароль пользователя, а вы проверяете их в… ну, там, где вы храните имена и пароли пользователей». Также несомненно, что эта стратегия не столь безопасна, как некоторые другие, но в качестве отправной точки вполне подходит.

Прямо сейчас в коде, с которым я работаю, вообще нет никакой аутентификации. Поэтому давайте не будем усложнять и просто «зашьем» фиксированные имя и пароль пользователя по месту. Когда вы увидите, как работает Passport, сравнительно легко понять, куда следует помещать код для поиска в базе данных для выполнения сравнения, так что пока я оставлю этот вопрос открытым.

Решив, что я хочу использовать стратегию passport-local, я начал с команды npm install --save passport-local, чтобы получить необходимые компоненты. (Вспомните, что аргумент --save помещает необходимую информацию в файл манифеста пакета, чтобы соответствующие компоненты автоматически отслеживались как формальная зависимость.)

После установки нужно сделать три вещи: во-первых, сконфигурировать Passport на использование данной стратегии, во-вторых, установить маршрут к HTTP URL, по которому пользователь будет посылать запрос на аутентификацию, и в-третьих, настроить промежуточный уровень Express на требование аутентификации, прежде чем разрешить пользователю обращаться к HTTP URL, о котором идет речь.

Конфигурация

Я начну с загрузки Passport в приложение. Предполагая, что passport и passport-local уже установлены, мне нужно загрузить их в скрипт app.js с помощью обычной магии require:

var express = require('express'),
  bodyParser = require('body-parser'),
  // ...
  passport = require('passport'),
  LocalStrategy = require('passport-local').Strategy;

Заметьте, что LocalStrategy задается слегка иначе; как и ранее с MongoClient, вы на самом деле присваиваете LocalStrategy результат обращения к полю Strategy из объекта, возвращенного вызовом require. Это несколько необычно в мире Node.js, но и не столь редко, чтобы назвать это уникальным явлением. LocalStrategy в данном случае будет служить своего рода классом (насколько это возможно в JavaScript), экземпляры которого вы будете создавать.

Мне также понадобится сообщить среде Express о том, что Passport работает:

var app = express();
app.use(bodyParser.json());
app.use(passport.initialize());

Вызов initialize достаточно понятен сам по себе; он подготавливает Passport к приему входящих запросов. Зачастую вы будете делать похожий вызов passport.session для настройки сеансов, индивидуальных для каждого пользователя, по аналогии с тем, что вы видели в ASP.NET, но в случае HTTP API и ему подобных то, что я создаю здесь, требуется реже или вовсе нежелательно (об этом мы поговорим чуть позже).

Трудная задача

Затем я должен установить обратный вызов, который будет запускаться Passport при приеме запроса на аутентификацию. Этот обратный вызов будет искать пользователя в базе данных и проверять переданный пароль. (Или, если брать более реалистичный сценарий, искать пользователя и проверять затемненный [salted] хеш пароля на соответствие затененному хешу пароля, хранящемуся в базе данных. Но этот сценарий на самом деле выходит за рамки самого Passport.) Это делается вызовом passport.use и передачей экземпляра Strategy, который нужно использовать, с обратным вызовом, встроенным в него, как показано на рис. 1.

Рис. 1. Установление обратного вызова

passport.use(new LocalStrategy(
  function(username, password, done) {
    debug("Authenticating ",username,",",password);
    if ((username === "sa") && (password == "nopassword")) {
      var user = {
        username : "ted",
        firstName : "Ted",
        lastName : "Neward",
        id : 1
      };
      return done(null, user);
    }
    else {
      return done(null, false, { message: "DENIED"} );
    }
  }
));

Здесь происходит несколько вещей. Во-первых, к моменту запуска обратного вызова Passport уже выполнил операции по разбору входящего запроса и извлечению имени и пароля пользователя, которые он передает в этом обратном вызове. В случае LocalStrategy Passport предполагает, что эти значения передаются через параметры username и password соответственно. (Если такие параметры неприемлемы, их можно сконфигурировать в вызове конструирования LocalStrategy.)

Во-вторых, реальный механизм верификации находится вне юрисдикции Passport; он предполагает, что верификацию выполняют стратегии, и в данном случае стратегия local полностью перекладывает это на код приложения. В этом примере вы просто осуществляете проверку сравнением с «зашитым» в код значением, но в более традиционных случаях поиск пользователя, чье имя совпадает с переданным в username, и последующая проверка пароля возлагалась бы на Mongo.

В-третьих, для соответствия обычному стилю промежуточного программного обеспечения Node.js уведомление об успехе или неудаче осуществляется функцией done с переданными параметрами, указывающими на успех или неудачу. Успех означает, что второй параметр является объектом user, который будет помещен в Express-объект request, передаваемый далее по конвейеру; неудача заставит Passport запросить Express вернуть ответ 401 (Not Authorized) и включить сообщение о неудаче (не обязательно), обычно применяемое для срочных сообщений (flash messages) в UI. (Если срочные сообщения не используются, такое сообщение в конечном счете будет отброшено.)

Последствия

Теперь остается лишь сконфигурировать маршрут, по которому будет происходить аутентификация:

app.post('/login',
  passport.authenticate('local', { session: false }),
  function(req, res) {
    debug("user ", req.user.firstName, " authenticated against the system");
    res.redirect("/persons");
  });

Passport не особенно заботит, какой шаблон URL применяется для аутентификации; /login — это просто соглашение, но вполне допустимы /signin, /user/auth или любая другая из полдесятка других вариаций. Главное в том, что первый шаг при разрешении этого маршрута должен быть вызовом passport-функции authenticateс передачей используемой стратегии (local), флага использования файлов cookie в сеансах, индивидуальных для каждого пользователя (что, как уже отмечалось, не совсем подходит для этого API), и функцию, которая будет вызываться при успешной аутентификации. Здесь эта функция просто записывает в журнал сообщение для отладки, а затем перенаправляет пользователя в список Persons, хранящийся в базе данных.

Теперь я могу проверить это, передав контент формы командой POST, либо отправив контент в формате JSON; поскольку это API, по-видимому, лучше и проще использовать JSON-пакет:

{ "username" : "sa" , "password" : "nopassword" }

Если имя пользователя и пароль совпадут с хранящимися на серверной стороне, возвращается ответ 302 — перенаправление к /persons; в ином случае возвращается ответ 401. Все работает!

Перенаправление трафика

По сути, это распространенный шаблон (при создании традиционных серверных веб-приложений с применением Express), который при успешной аутентификации перенаправляет пользователя по заданному маршруту, а при неудаче должен отправить его на новую страницу; по этой причине Passport предоставляет более простой подход к обратным вызовам в функции authenticate:

app.post('/login',
  passport.authenticate('local', { successRedirect: '/',
                                   failureRedirect: '/login',
                                   failureFlash: true })
);

Здесь при успешном выполнении Passport будет автоматически перенаправлять на URL «/», а при неудаче — обратно на URL «/login» и (в данном случае) выводить срочное сообщение, указывающее, что пользователю не удалось войти.

Однако в случае какого-либо API более распространен вариант, при котором клиенту возвращается JSON-представление объекта user для отображения и редактирования. При этом учтите, что в этом процессе нельзя отправлять обратно что-либо, связанное с защитой или с конфиденциальностью, в частности пароли. Все браузеры чрезвычайно услужливы, предоставляя средства отладки на клиентской стороне, и в итоге любой атакующий мог бы очень легко добраться до этого объекта user, хранящегося в памяти браузера, и приступить к его модификации. Это было бы весьма скверно. (Эти JSON-объекты можно также модифицировать «на лету», из-за чего большинство API-систем на основе Node.js работают по HTTPS, а не по HTTP. К счастью, конфигурирование Express на работу по HTTPS вместо HTTP в основном можно считать в большей мере упражнением в настройке облачной конфигурации, чем каким-либо программным изменением.) В результате пароли никогда не должны оставаться на серверах, а роли (в случае системы авторизации на основе ролей) должны всегда проверяться из базы данных, а не из объекта user, переданного запросом.

Однако, как уже говорилось, прямо сейчас API-клиенту понадобится передавать удостоверения аутентификации всякий раз, когда он попадает на маршрут /login, и эти удостоверения не проверяются на любых других маршрутах. Хотя я, разумеется, мог бы поместить проверки аутентификации по каждому маршруту (и должен был бы, если вдуматься), мне не хочется передавать удостоверения как часть вызова каждого метода.

Альтернативы

Первая альтернатива: вы всегда можете вернуться к включению сеансов; когда сеансы включены, Passport будет создавать уникальный идентификатор и передавать его обратно как часть HTTP-ответа в виде cookie. После этого клиенты должны отправлять этот cookie как часть каждого последующего запроса. Основное требование к этому моменту на серверной стороне заключается в том, что библиотека Passport должна знать, как преобразовать объект user в идентификатор и обратно; соответствующие операции называют сериализацией и десериализацией объекта user, и это требует подготовки двух обратных вызовов для каждой из следующих двух конечных точек Passport:

passport.serializeUser(function(user, done) {
  done(null, user.id);
});
passport.deserializeUser(function(id, done) {
  User.findById(id, function(err, user) {
    done(err, user);
  });
});

Функция serializeUser предоставляет уникальный идентификатор для пользователя в Passport (поэтому я получаю его из поля user.id), а функция deserializeUser делает обратное (поэтому я использую переданный id как основной ключ при поиске объекта пользователя в базе данных).

Вы можете включить сеансы для большинства (если не для всех) стратегий Passport, но, как правило, это работает, когда сервер генерирует HTML для прямой интерпретации браузером. API обычно не работают так активно с cookie, особенно потому, что к API зачастую в большей мере обращаются клиентские мобильные приложения, а не клиенты на основе браузеров.

Второй подход использует другую стратегию Passport, которая опирается на секрет, известный как клиенту, так и серверу. Он можете передаваться самыми разнообразными способами. В некоторых случаях система поддерживает известный набор выданных API-ключей, и вы должны предоставлять такой ключ в каждом запросе. Это весьма распространенная практика в целом ряде сторонних REST-сервисов, но здесь есть серьезная уязвимость в том плане, что, если атакующий сумеет получить ключ, он сможет маскироваться под клиента, пока клиент не сбросит этот ключ. В Passport есть стратегия для такого варианта; используйте команду npm install --save passport-localapikey. Она ведет себя во многом так же, как стратегия Local, с тем исключением, что метод аутентификации в стратегии localapikey будет искать API-ключ в базе данных, а не имя и пароль пользователя.

В похожем подходе используются JSON Web Tokens (JWT), которые являются более защищенными, но требуют куда более пространных пояснений, чем я могу позволить себе в этой статье; команда npm install --save passport-jwt включит JWT в проект. JWT являются упакованным набором множества различных элементов данных, один из которых может быть общим секретом (а-ля API-ключ или пароль), но может проверяться применительно к конкретным издателям, аудитории и др.

Или, возможно, поступить иначе — вообще не хранить никакие удостоверения, а полагаться в аутентификации на сторонние системы (вроде Facebook, Google, Twitter, LinkedIn или любого другого из нескольких сотен популярных сайтов). Passport и здесь поможет, предоставляя специфические стратегии для каждого из этих сайтов, а также универсальные стратегии OAuth 2.0 (и OpenID для сайтов, которых это поддерживают).

Я думаю, что суть становится понятна: какую бы систему аутентификации вы ни вообразили, в Passport уже есть для нее стратегия. Просто используйте команду npm install, настраивайте конфигурацию, помещайте вызов authorize в маршруты Express — и вперед.

Кстати, важно отметить, что в Интернете есть сервисы, которые будут предоставлять единую точку управления доступом для всех видов аутентификации. Эти сервисы «Authentication-as-a-Service» становятся все популярнее по мере увеличения количества сайтов, используемых людьми на регулярной основе и создающими для администраторов все больше головной боли. Один из моих любимых — Auth0 (в этой компании, кстати, работает несколько бывших сотрудников Microsoft) — является спонсором проекта Passport, и ее эмблемы и значки разбросаны по всему сайту Passport. Я настоятельно советую проверить, не появилась ли уже в этом проекте подходящая вам предопределенная стратегия, например, для аутентификации в устаревшей системе или для интеграции с Facebook, Dropbox или других используемых вами сайтов).

Заключение

Несомненно, Passport — самый успешный изо всех когда-либо разрабатывавшихся проектов аутентификации, способный работать с любым языком или платформой. Он умудряется предоставлять необходимые точки подключения аутентификации, в то же время оставляя на ваш выбор конкретные средства аутентификации. Более того, он позволяет вам либо контролировать все самостоятельно, либо передавать ему всю черную работу. Подход со стратегиями означает, что он бесконечно расширяем и может подстраиваться под любые новые виды схем аутентификации, которые могут появиться в будущем даже через 20 лет. (Не смейтесь — вся эта обвязка JavaScript еще будет активно использоваться не менее 20 лет. Вот увидите.)

Но в понятие Passport заложено как то, что он делает, так и то, чего он не делает. Passport занимается исключительно проверкой удостоверений, а не шифрованием или еще чем-то, что, разумеется, делает его название гораздо понятнее. Тут полная аналогия с поездкой в Европу: я должен показать свой паспорт, чтобы подтвердить, что я американский гражданин. Вот и Passport требует от пользователей показывать свои удостоверения, чтобы они могли подтвердить, что являются добропорядочными гражданами в системе.

И вновь у меня кончилось место и время, так что… удачи в кодировании!


Тэд Ньюард (Ted Neward) — глава фирмы Neward & Associates, предоставляющей консалтинговые услуги по самым разнообразным технологиям. Автор и соавтор многочисленных книг, в том числе «Professional F# 2.0» (Wrox, 2010), более сотни статей, часто выступает на многих конференциях по всему миру; кроме того, имеет звание Microsoft MVP в области F#. С ним можно связаться по адресу ted@tedneward.com или почитать его блог blogs.tedneward.com.

Выражаю благодарность за рецензирование статьи эксперту Шону Уайлдермуту (Shawn Wildermuth).


Discuss this article in the MSDN Magazine forum