Открыть список
Как стать автором
Обновить
70,49
Рейтинг

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

Блог компании МегаФонJavaScriptNode.JS

На текущий момент я работаю в компании «МегаФон» тимлидом фронта. С начала 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. Если же все хорошо, то само собой мы отдадим данные =)

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

В заключение

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

Теги:авторизацияsocket.ioавторизация пользователя
Хабы: Блог компании МегаФон JavaScript Node.JS
Всего голосов 8: ↑6 и ↓2 +4
Просмотры4.2K

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

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

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

Лучшие публикации за сутки

Информация

Дата основания
Местоположение
Россия
Сайт
job.megafon.ru
Численность
свыше 10 000 человек
Дата регистрации

Блог на Хабре