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

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

В заключение

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