Еще раз о passport.js

    Недавно мне передали на поддержку проект на express.js. При изучении кода проекта я обнаружил немного запутанную работу с аутентификацией/авторизацией которая базировалась, как и 99,999% случаев, на библиотеке passport.js. Этот код работал, и следуя принципу «работает — не трогай», я оставил его как есть. Когда через пару дней мне дали задание добавить еще две стратегии авторизации. И тогда я начал вспоминать, что уже делал аналогичную работу, и это занимало несколько строк кода. Полистав документацию на сайте passport.js, я почти не сдвинулся с места в понимании того, что и как нужно делать, т.к. там рассматривались случаи, когда используется ровно одна стратегия, для которой, для каждой в отдельности, и даются примеры. Но как соединить несколько стратегий, зачем нужно использовать метод logIn() (что то же самое, что login()) — по-прежнему не прояснялось. Поэтому, чтобы разобраться сейчас, и не повторять тот же поиск еще и еще раз, — я составил для себя эти заметки.

    Немного истории. Изначально веб-приложениях использовали два вида аутентификации/авторизации: 1) Basic и 2) при помощи сессий с использованием cookie. В Basic аутентификации/авторизации в каждом запросе передается некоторый заголовок, и, таким образом, в каждом запросе проводится аутентификация клиента. При использовании сессий, аутентификация клиента проводится только один раз (способы могут быть самые различные в том числе и Basic, а еще по имени и паролю, которые отправляются в форме, и еще тысячи других способов, которые в терминах passport.js называются стратегиями). Главное, что после прохождения аутентификации, клиенту в cookie сохраняется идентификатор сессии (или в некоторых реализациях данные сессии), а в данных сессии сохраняется идентификатор пользователя.

    Для начала нужно определиться будете ли Вы использовать в своем приложении сессии при аутентификации/авторизации. Если Вы разрабатываете бэкэнд мобильного приложения — то, скорее всего, нет. Если это веб-приложение — то, скорее всего, да. Для использования сессий нужно активировать cookie-parser, session middleware, а также инициализировать сессию:

    const app = express();
    
    const sessionMiddleware = session({
      store: new RedisStore({client: redisClient}),
      secret,
      resave: true,
      rolling: true,
      saveUninitialized: false,
      cookie: {
        maxAge: 10 * 60 * 1000,
        httpOnly: false,
      },
    });
    
    app.use(cookieParser());
    app.use(sessionMiddleware);
    app.use(passport.initialize());
    app.use(passport.session());
    

    Тут нужно дать несколько важных пояснений. Если Вы не хотите, чтобы redis через пару лет работы съел всю оперативную память, нужно позаботиться о своевременном удалении данных сессии. За это отвечает параметр maxAge, который в равной степени устанавливает это значение и для cookie, и для значения сохраняемого в redis. Установка значений resave: true, rolling: true, продлевает срок действия заданным значением maxAge при каждом новом запросе (если это нужно). В противном случае сессия клиента будет периодически прерываться. И, наконец, параметр saveUninitialized: false не будет помещать в redis пустые сессии. Это позволяет разместить инициализацию сессий и passport.js на уровне приложения, не засоряя redis лишними данными. На уровне роутов инициализацию имеет смысл размещать только в том случае, если метод passport.initialize() необходимо вызывать с разными параметрами.

    Если сессия не будет использоваться то инициализация значительно сократится:

    app.use(passport.initialize());
    


    Далее нужно создать объект стратегии (так в терминологии passport.js называют способ аутентификации). Каждая стратегия имеет свои особенности конфигурирования. Неизменным остается только то, что в конструктор стратегии передается callback-функция, которая формирует объект user, доступный как request.user для следующих в очереди middleware:

    const jwtStrategy = new JwtStrategy(params, (payload, done) =>
      UserModel.findOne({where: {id: payload.userId}})
        .then((user = null) => {
          done(null, user);
        })
        .catch((error) => {
          done(error, null);
        })
    );
    


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

    Далее нужно дать команду на использование стратегии. Каждая стратегия имеет имя по умолчанию. Но его можно задать и явно, что позволяет использовать одну стратегию с разными параметрами и логикой callback-функции:

    passport.use('jwt', jwtStrategy);
    passport.use('simple-jwt', simpleJwtStrategy);
    


    Далее для защищаемого роута необходимо задать стратегию аутентификации и важный параметр session (по умолчанию равный true):

    const authenticate = passport.authenticate('jwt', {session: false});
    router.use('/hello', authenticate, (req, res) => {
      res.send('hello');
    });
    


    Если сессия не используется, то защищать аутентификацией нужно все роуты с ограниченным доступом. Если же сессия используется, то аутентификация происходит однократно, и для этого задается специальный роут, например login:

    const authenticate = passport.authenticate('local', {session: true});
    
    router.post('/login', authenticate, (req, res) => {
      res.send({}) ;
    });
    
    router.post('/logout', mustAuthenticated, (req, res) => {
      req.logOut();
      res.send({});
    });
    


    При использовании сессии, на защищаемых роутах, как правило, используется очень лаконичное middleware (которое почему-то не включено в библиотеку passport.js):

    function mustAuthenticated(req, res, next) {
      if (!req.isAuthenticated()) {
        return res.status(HTTPStatus.UNAUTHORIZED).send({});
      }
      next();
    }
    


    Итак, остался один последний момент — сериализация и десериализация объекта request.user в сессию/из сессии:

    passport.serializeUser((user, done) => {
      done(null, user.id);
    });
    
    passport.deserializeUser((id, done) => {
      UserModel.findOne({where: {id}}).then((user) => {
        done(null, user);
        return null;
      });
    });
    


    Хочу еще раз подчеркнуть, что сериализация и десериализация работает только со стратегиями, для которых задан атрибут {session: true}. Сериализация будет выполнена ровно один раз сразу после аутентификации. Поэтому обновить данные сохраненные в сессии будет весьма проблематично, в связи с чем сохраняется только идентификатор пользователя (который не меняется). Десериализация будет выполняться при каждом запросе к защищенному роуту. В связи с чем запросы к базе данных (как в примере) существенно влияют на производительность приложения.

    Замечание. Если Вы используете несколько несколько стратегий одновременно, для всех этих стратегий будет работать один и тот же код сериализации/десериализации. Для учета стратегии по которой прошла аутентификация можно например включить в объект user признак стратегии. Так же не имеет смысла несколько раз вызывать метод initialize() с разными значениями. Он все равно будет переписан значениями из последнего вызова.

    На этом можно было бы и закончить повествование. Т.к. кроме уже сказанного, на практике ничего другого не требуется. Однако, мне пришлось по требованию разработчиков фронтенда добавить в 401 ответ объект с описанием ошибки (по умолчанию это строка «Unauthorized»). И это, как оказалось, не получается сделать просто. Для таких случаев нужно еще немного глубже залезть в ядро библиотеки, что не так уж и приятно. У метода passport.authenticate есть третий опциональный параметр: callback-функция с сигнатурой function(error, user, info). Небольшая проблема заключается в том, что этой функции не передается ни объект response, ни какaя-нибудь функция типа done()/next(), в связи с чем приходится самостоятельно преобразовывать ее в middleware:

    route.post('/hello', authenticate('jwt', {session: false}),  (req, res) => {
      res.send({}) ;
    });
    
    function authenticate(strategy, options) {
      return function (req, res, next) {
        passport.authenticate(strategy, options, (error, user , info) => {
          if (error) {
            return next(error);
          }
          if (!user) {
            return next(new TranslatableError('unauthorised', HTTPStatus.UNAUTHORIZED));
          }
          if (options.session) {
            return req.logIn(user, (err) => {
              if (err) {
                return next(err);
              }
              return next();
            });
          }
          req.user = user;
          next();
        })(req, res, next);
      };
    }
    


    UPD1:



    Недавно столкнулся с задачей задать несколько стратегий авторизации на один роут. Как выяснилось, это можно сделать, передав массив строк в функцию авторизации:

    app.post('/test', passport.authenticate(['simple-jwt', 'jwt'], { session: false }), (req, res) => { ...
    


    При этом, все стратегии применяются с условием логическое ИЛИ. Срабатывает первая из удачных стратегий. Если произошла ошибка, и ее обработали функцией done(err), то дальше авторизация не проходит. Поэтому во всех стратегиях, кроме последней в списке авторизации, ошибка должна обрабтываться вызовом done(null, false).

    UPD2:



    Если у Вас не нашлось подходящей стратегии — в качестве конструктора новых стратегий можно использовать стратегию passport-custom:

    const { Strategy: CustomStrategy } = require('passport-custom');
    module.exports = new CustomStrategy(
      (req, done) => {
            ...
            done(nul, user)
            ...
            done(null, false);
            ...
      }
    );
    


    Полезные ссылки:

    1) toon.io/understanding-passportjs-authentication-flow
    2) habr.com/post/201206
    3) habr.com/company/ruvds/blog/335434
    4) habr.com/post/262979
    5) habr.com/company/Voximplant/blog/323160
    6) habr.com/company/dataart/blog/262817
    7) tools.ietf.org/html/draft-ietf-oauth-pop-architecture-08
    8) oauth.net/articles/authentication

    apapacy@gmail.com
    4 января 2019 года
    image

    Similar posts

    Ads
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More

    Comments 20

      +1
      За все время работы с этой библой, я так и не понял пользу от нее. Выпилил ее и не жалею!
        +1
        Вы пишете авторизацию через все соц-сети вручную?
          +2
          Не через все, а те которые необходимы. и это один раз было проделано. Проблем вообще никаких.
        +1
        JWT придуман для того, чтобы каждый раз не обращаться к базе, иначе там смысла мало. Вы сохраняете user_id, role и ещё что нибудь туда и при получении токена (если подпись верна), можете смело использовать значения, не ходя за ними в базу.

        Т.е. вот так делать не стоит…
        new JwtStrategy(params, (payload, done) =>
          UserModel.findOne({where: {id: payload.userId}})
          0
          Согласен. Но вопросы начинают возникать когда эти данные меняются. Например пользователь администратор уволился с работы и я должен убрать у него роль admin.
            +6
            Для этого вам нужно просто отозвать его токен. При следующей авторизации, новый токен будет создан с новой ролью.
              +1

              jwt живет только на протяжении сессии, если ваш админ уволился то вы убираете роль, и при следующем логине в его jwt роль админ у него не будет.

                +2
                Это вопрос инвалидации JWT в принципе. И решается кучей разных способов. Можно делать blacklist, можно отзывать secret, можно делать шардирование secret и отзывать один из 10 к примеру (чтобы 90% не вылетали) и тп.
                  0
                  Вобщем-то, с одной стороны все ясно. Нужно пользоваться access+refresh токенами. Первые ограничены по времени действия и поэтому при следующем запросе access токена опять будут актуальные данные. Однако такой путь на мой взгляд приносит усложнение в фроненд. Во всяком случае пока для iOS и Android не появится широко распространенная и всеми используемая библиотека которая будет выполнять эту работу за кадром.

                  Что касается идеи с отзывом токенов. Ее как мне кажется целесообразно использовать в связке с access+refresh токенами для «мгновенного» отзыва до следующей ротации access токенов. Иначе мы получим базу отозванных токенов запросы к которой будут сравнимы по затратам с запросами профиля пользователя.

                  Но все это как-то начинает усложнять схему особенно в отсутствии некоторого фреймверка который бы все это делал за кадром, и который должен поддерживаться на всех оcновных клиентах (iOS, Android, web) и на сервере (желательно тоже не на одном)
              +2
              Иначе мы получим базу отозванных токенов запросы к которой будут сравнимы по затратам с запросами профиля пользователя

              В примере с уволенным админом достаточно держать в ENV переменной что-то вроде BLACKLIST_JWT через запятую. Вы не каждый день увольняете людей, поэтому так можно. Современный процессы continuous deployment позволяют менять env в течении пары минут.

              Или ещё проще — делать blacklist на уровне балансера (nginx без проблем через map и if). Или в redis держать backlist jwt. Все очень сильно зависит от задач и проекта. Очень часто не нужно ничего навороченного.

              apapacy промазал, для вас ответ.
                +1
                В примере с уволенным админом достаточно держать в ENV переменной что-то вроде BLACKLIST_JWT через запятую. Вы не каждый день увольняете людей, поэтому так можно. Современный процессы continuous deployment позволяют менять env в течении пары минут.

                Эмм… это вы сейчас серьезно предлагаете делать нового приложения каждый раз когда надо сделать токен не валидным? Или у нас разное понимание слова «deployment»?
                  0
                  Опечатался, delivery. Просто правка файла, git push и через минуту приложение обновилось.
                    +2
                    Честно говоря — исправление опечатки не сильно помогло) Суть ведь не поменялась. Вы все еще пытаетесь решить хардкодом то, что должно решаться изменением данных. То есть — делаете вашу систему невозможной к использованию обычным пользователем. Да еще и используете в качестве хранилища — совершенно не подходящее место.
                    Можно, конечно, принять оговорку про разные проекты — но мне очень тяжко придумать где я хотел бы вешать на себя такой геморрой вместо того чтоб юзер становился заблокированным от одного нажатия на кнопку «уволить».
                      0
                      Я несколько раз повторил, что это зависит от проекта и задачи, привел несколько вариантов решения. а вы выбрали простейший и говорите, что он «не для пользователя». Вам нужно — пишите. А я ради события, которое раз в год происходит не буду писать код, который нужно поддерживать, тестировать и не сломать через пару лет. Каждый день — напишу, каждую неделю — напишу, а увольнения админа в вакуме — нет.
                +1
                Время от времени в разного рода статьях читаю про то, что `UserModel.find({id:...})` будет заметно влиять на производительность.

                Но скажите, какую функцию выполняет приложение? Если один запрос с хорошим слючом по небольшой компактной таблице заметно влияет на производительность.
                  0
                  Обычно по производительности база данных это первый ограничивающий фактор с которым сталкивается приложение. Не совсем понимаю что такое хороший ключ и может ли быть таблица компактной если у меня несколько миллионов пользователей. И какое отношение имеет функция приложения к авторизации. Просто есть приложения где не нужна авторизация и есть приложение в котором нужна авторизация. И если авторизация нужна тогда нужен объект user. Откуда его брать это уже следующий вопрос. Можно из подписанного токена или из сессии. Можно лезть в основную базу данных. Можно для этого организовать отдельную базу данных или сервис.
                    +1
                    Хороший ключ это уникальное короткое поле, выборка по которому происходит максимально эфективно.

                    Таблица с юзерскими авторизационными записями (если в нее не пихать весь профайл, конечно) компактна и там не больше одного чтения на запись получается — она влазит в буфер с запасом. Миллион таких записей это примерно гиг, а реальный ворксет вообще легко в ОЗУ помещается.

                    Это я все к тому, что вот этот пример с сессиями в базе встречается довольно часто рядом со словом «производительность», но на деле тут очень не много получается соптимизировать в принципе. А вот найти себе неочевидного гемора с размазыванием сейта по токенам можно найти очень легко.
                  +2
                  Мне кажется более точного говорить не BASE, а Basic. Именно так это поле называется в HTTP заголовках.
                    +1
                    Также сталкивались с подобной задачей:
                    по требованию разработчиков фронтенда добавить в 401 ответ объект с описанием ошибки (по умолчанию это строка «Unauthorized»)

                    Была найдена альтернатива failWithError: true из «коробки» passport.js (правда, не описанная в официальной документации), которая позволяет вернуть кастомное сообщение с ошибкой без реализации кастомного коллбека. Пример:

                    app.post('/auth',
                      passport.authenticate('local', { failWithError: true }),
                      function(req, res, next) {
                        // Successful auth
                        return res.send({ success: true, message: 'Logged in' })
                      },
                      function(err, req, res, next) {
                        // Auth error
                        return res.status(401).send({ success: false, message: err })
                      }
                    )
                      0
                      Тоже читал это issue только не был уверен что это уже в релизе. Скорее всего перейду на этот вариант. Хотя стоп. Это же в каждый роут нужно будет добавить? Хотелось бы сделать это один раз

                    Only users with full accounts can post comments. Log in, please.