На текущий момент я работаю в компании «МегаФон» тимлидом фронта. С начала 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 - пространство для событий, которые могут идти/поступать только для администраторов), а общее пространство теперь у нас считается "публичным" и доступно для всех подключенных, но не авторизованных пользователей. Таким образом, процесс получения доступов на сокетах выглядит так:
Клиент аутентифицируется по http, по паре логин/пароль, получает в ответ jwt токен и куку с id сессии.
Далее юзер коннектится к нашей точке входа для socket.io (например: localhost:8080/sockets). Теперь у него есть доступ до публичных событий на наших сокетах.
Если он хочет получить доступ до всех наших событий, которые ему доступны по роли, то он отправляет событие auth_login по сокетам, с jwt токеном, который он получил от http авторизации.
Система проверяет токен и по результатам проверки генерирует одно из двух событий.
auth_loginFailed - пользователю не будут предоставлены доступы, так как токен кривой или просрочен
auth_loginSuccess - все хорошо, можно продолжать
Если проверка прошла успешно, то сервер добавляет пользователя во все пространства предоставленные ему по его роли.
Пользователю теперь доступны аутентифицированные и скрытые ранее события.
Когда у токена проходит "срок годности", сокеты генерируют событие auth_expire, говорящее, что более пользователю недоступны ранее предоставленные комнаты.
Token steal
Вишенкой на торте в данной картине механизма авторизации/аутентификации стал результат изучения проблемы кражи токенов. Ради минимизации рисков от попадания в такую ситуацию, было решено улучшить механизмы работы с авторизационным токеном. Во время исследования данной темы наткнулся на статью на хабре - Зачем нужен Refresh Token, если есть Access Token?. Очень советую ознакомиться, но если кратко, то вот результирующая цитата:
Таким образом, схема refresh + access токен ограничивает время, на которое атакующий может получить доступ к сервису. По сравнению с одним токеном, которым злоумышленник может пользоваться неделями и никто об этом не узнает.
Однако, у нас уже есть два токена:
id серверной сессии от express-session, который ходит в куках, всегда
jwt токен, который генерируется после логина пользователя
Поэтому было бы логично реализовать похожий механизм, как и в OAuth2. Для этого мы стали хранить jwt токен в сессии и сравнивать его с полученным от клиента при проверке аутентификации. Остается только одна проблема - несколько токенов в одной сессии. Необходимо это для того, чтобы иметь возможность войти и в админку, и на фронт. Для этого считаем, что клиент передаст свое "название" при логине, и под этим названием мы и сохраним токен в сессию, а также записываем само название внутрь токена для дальнейшей проверки. За счет вышеописанного получается следующая схема взаимодействия:
Клиент отправляет пару «логин и пароль», плюс к этому уникальный ключ - название себя (то есть имя приложения).
Система, если пара «логин и пароль» найдена, генерирует jwt токен, включая в него название клиента (ключ), отправляет его клиенту и записывает в сессию.
При последующем обращении клиента по роуту, который закрыт проверкой аутентификации, проверяется наличие токена, его валидность, просрочен ли он. Далее сервер вытаскивает из токена название клиента и смотрит, есть ли такой токен с таким ключом в текущей сессии.
Если вдруг токен и куку украл злоумышленник, то при первом же рефреше с любой из сторон (клиент или злоумышленник), сторона, которая попытается обратиться со старой парой «токен + кука», получит негативный результат. Система поймет, что токен не соответствует тому, что хранит сессия и очистит сессию полностью, что привет к выбросу и клиента и злоумышленника, а факт кражи будет зафиксирован в системе.
Если же все хорошо, то само собой мы отдадим данные =)
Таким образом, мы получили многосессионный механизм (один пользователь может зайти сразу с нескольких браузеров в одно приложение) аутентификации, который работает еще и несколько приложений сразу.
В заключение
Надеюсь, описанная выше логика работы поможет читателям реализовать достаточно защищенные веб-сервера. На текущий момент в статье приведено мало снипетов кода, так как логика разбросана по разным файлам и частям кода, и собрать ее воедино будет проблематично. Но если будет проявлен интерес в комментариях к решению той или иной проблемы, я постараюсь дополнить статью тем или иным куском кода, примером и т.п.