company_banner

Авторизация и аутентификация на NodeJs и Socket.io и проблемы вокруг

    На текущий момент я работаю в компании «МегаФон» тимлидом фронта. С начала 2020 года мы в команде МегаФона разрабатываем собственную платформу Интернета вещей. Так как в таком процессе нагрузка на бэк-энд разработчиков стала колоссальной, а фронт не так активно задействован, внутри отдела было принято решение отдать всю веб-часть в руки моей команды. Очевидно, что мы взяли NodeJs с ExpressJS, и занялись построением серверной архитектуры.

    Для корректного доступа к собранным с устройств данным нужна была авторизация, чтобы понимать, кто и что может получить/сделать. Про очевидный путь с passportJs, думаю, нет смысла рассказывать. По логину и паролю мы стали отдавать jwt токен, и изначально на этом успокоились.

    После этого нам потребовалось хранить в сессии данные, специфичные для каждого пользователя. Логичным решением было бы использовать сам jwt токен, хранить информацию в нем и гонять от клиента к серверу. Однако, данное решение не подходило нам из-за использования веб-сокетов (в нашем случае мы взяли socket.io), так как в данном протоколе передача хедера Authorization с jwt токеном невозможна (в соответствии со стандартом). Единственный вариант - передавать хедер в параметрах url. Но это не очень здорово - токены будут легко видны во всех логах всех прокси-серверов. Хорошим решением оказалось использование сессии, которая хранится полностью на серверной стороне, и по сети ходит лишь id этой сессии. Мы выбрали - express-session.

    Объединенная сессия

    Отдельной проблемой стала необходимость получения актуального состояния сессии и возможность его изменения в событиях веб-сокетов. Для этого идеально подошел пакет - express-socket.io-session. Правда, пришлось поколдовать над её подключением:

    Изменили подключение сессии и настройки кук:

    this.store = new pgSession({
              pool: pgPool,
              tableName: SESSION_TABLE
          });
    
    this.session = expressSession({
        name: SESSION_KEY,
        secret: SESSION.secret,
        resave: false, // важно, для того, чтобы сессия не перезаписывалась на каждый чих
        rolling: true,
        saveUninitialized: true, // нужно для выдачи куки даже неавторизированному пользователю
        proxy: true,
        cookie: {
            secure: true, // обязывает производить передачу по ssl
            maxAge: SESSION_DURATION,
            sameSite: 'none' // чтобы можно было отдавать на разные поддомены
        }
        store: this.store
    });
    

    Мы написали обработчики сессии таким образом, чтобы сессия подгружалась до начала обработки события, и сохранялась, если необходимо:

    const asyncHandlerExtended = (fn, socket) => (data) => {
        const cb = async () => {
            await reloadSession(socket.handshake.session);
            await fn({ socket, data });
            await saveSession(socket.handshake.session);
        };
        return Promise.resolve(cb()).catch((err) => {
            socket.emit('error', err);
        });
    };

    Собрали все вместе при настройке сокетов:

    import sharedSession from 'express-socket.io-session';
    import io from 'socket.io';
    
    const resultSocket = nameSpace ? this.io.of(nameSpace) : this.io;
    resultSocket.use(sharedSession(session, { autoSave: true }));

    Разделение сокетов по ролям

    Дальше нам нужно понимание того, кому и какие события можно получать на сокетах, а какие - нет. Для этого отлично подходит механизм комнат в socket.io. Он позволяет серверу формировать пространства, в которые можно "запускать" пользователей и эмитить в них разные события. Мы выделили под каждую из ролей пользователей отдельную комнату (например комната adminRoom - пространство для событий, которые могут идти/поступать только для администраторов), а общее пространство теперь у нас считается "публичным" и доступно для всех подключенных, но не авторизованных пользователей. Таким образом, процесс получения доступов на сокетах выглядит так:

    1. Клиент аутентифицируется по http, по паре логин/пароль, получает в ответ jwt токен и куку с id сессии.

    2. Далее юзер коннектится к нашей точке входа для socket.io (например: localhost:8080/sockets). Теперь у него есть доступ до публичных событий на наших сокетах.

    3. Если он хочет получить доступ до всех наших событий, которые ему доступны по роли, то он отправляет событие auth_login по сокетам, с jwt токеном, который он получил от http авторизации.

    4. Система проверяет токен и по результатам проверки генерирует одно из двух событий.

      1. auth_loginFailed - пользователю не будут предоставлены доступы, так как токен кривой или просрочен

      2. auth_loginSuccess - все хорошо, можно продолжать

    5. Если проверка прошла успешно, то сервер добавляет пользователя во все пространства предоставленные ему по его роли.

    6. Пользователю теперь доступны аутентифицированные и скрытые ранее события.

    7. Когда у токена проходит "срок годности", сокеты генерируют событие auth_expire, говорящее, что более пользователю недоступны ранее предоставленные комнаты.

    Token steal

    Вишенкой на торте в данной картине механизма авторизации/аутентификации стал результат изучения проблемы кражи токенов. Ради минимизации рисков от попадания в такую ситуацию, было решено улучшить механизмы работы с авторизационным токеном. Во время исследования данной темы наткнулся на статью на хабре - Зачем нужен Refresh Token, если есть Access Token?. Очень советую ознакомиться, но если кратко, то вот результирующая цитата:

    Таким образом, схема refresh + access токен ограничивает время, на которое атакующий может получить доступ к сервису. По сравнению с одним токеном, которым злоумышленник может пользоваться неделями и никто об этом не узнает.

    Однако, у нас уже есть два токена:

    • id серверной сессии от express-session, который ходит в куках, всегда

    • jwt токен, который генерируется после логина пользователя

    Поэтому было бы логично реализовать похожий механизм, как и в OAuth2. Для этого мы стали хранить jwt токен в сессии и сравнивать его с полученным от клиента при проверке аутентификации. Остается только одна проблема - несколько токенов в одной сессии. Необходимо это для того, чтобы иметь возможность войти и в админку, и на фронт. Для этого считаем, что клиент передаст свое "название" при логине, и под этим названием мы и сохраним токен в сессию, а также записываем само название внутрь токена для дальнейшей проверки. За счет вышеописанного получается следующая схема взаимодействия:

    1. Клиент отправляет пару «логин и пароль», плюс к этому уникальный ключ - название себя (то есть имя приложения).

    2. Система, если пара «логин и пароль» найдена, генерирует jwt токен, включая в него название клиента (ключ), отправляет его клиенту и записывает в сессию.

    3. При последующем обращении клиента по роуту, который закрыт проверкой аутентификации, проверяется наличие токена, его валидность, просрочен ли он. Далее сервер вытаскивает из токена название клиента и смотрит, есть ли такой токен с таким ключом в текущей сессии.

    4. Если вдруг токен и куку украл злоумышленник, то при первом же рефреше с любой из сторон (клиент или злоумышленник), сторона, которая попытается обратиться со старой парой «токен + кука», получит негативный результат. Система поймет, что токен не соответствует тому, что хранит сессия и очистит сессию полностью, что привет к выбросу и клиента и злоумышленника, а факт кражи будет зафиксирован в системе.

    5. Если же все хорошо, то само собой мы отдадим данные =)

    Таким образом, мы получили многосессионный механизм (один пользователь может зайти сразу с нескольких браузеров в одно приложение) аутентификации, который работает еще и несколько приложений сразу.

    В заключение

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

    МегаФон
    Компания

    Похожие публикации

    Комментарии 5

      +3
      Какой смысл в использовании JWT, если всё равно используются сессии на стороне сервера?
      Я думал его основная цель — сокращение числа запросов к хранилищу, которое требуется для серверной сессии.

      В начале вы пишете, что с сокетами JWT не взлетел
      так как в данном протоколе передача хедера Authorization с jwt токеном невозможна
      и единственная альтернатива — параметры URL, а в итоге получается, что вы используете другую альтернативу
      он отправляет событие auth_login по сокетам, с jwt токеном
      — передаёте JWT в теле сообщения через сокеты.
      Наверное я что-то неправильно понял. Впрочем как и с той статьёй, что вы рекомендовали
      Зачем нужен Refresh Token, если есть Access Token?.
      Когда я её в своё время прочитал вместе с комментариями, показалось, что никто на самом деле и не знает, зачем нужен Refresh токен, и вообще как и какие проблемы решает JWT, а какие создаёт. Чего стоят одни только вопросы реализации отзыва токена и безопасности его хранения на стороне пользователя.
        0
        Да, на самом деле многие не понимают суть токенов. Токены нужны когда у вас кроссдоменное и кроссплатформенное приложение, например есть сайт(web) и мобильное приложение. Вообще лучше гибрид — для веб — сессионные куки, для мобайл и тп — токены.
        0

        Я считал, что основной смысл jwt — убрать проблему синхронизации сессий между бэкенд серверами. Если есть сессии зачем токен?

          0

          Если использовать WSS, и производить авторизацию через сообщения, то получить токен возможно лишь при доступе к серверу или к устройству (ну или при взломе SSL|TLS если вы имеете соответствующий квантовый ПК).
          Если есть доступ к устройству, то без проблем можно получать актуальный токен для каждого запроса.
          Если используется не шифрованный канал, то можно добавить лимит действия токена после которого необходимо обновить токен. Если Алиса обновит токен и следом, раньше положенного времени Боб попытается обновить, то токен инвалидируется и наоборот. К примеру обновление токена каждые 30 минут, и два и более клиентов не смогут сделать обновления токена в заданный интервал. Если у Алисы не было доступа к интернету, то она может обновить его когда подключится и продолжить интервальные обновления.
          Если я что то упускаю Welcome!

            0
            Скажите, пожалуйста, почему не подошло какое-нибудь готовое решение? Типа Firebase?

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое