Защита JWT для аутентификации, httpOnly cookies, CSRF-токены
Часто говорят: Не храните токены в локальном хранилище (или хранилище сессий). Если какой-либо сторонний скрипт, который вы включаете в свою страницу, будет взломан, он сможет получить доступ ко всем токенам ваших пользователей.
localStorage действительно небезопасен. Но если не в localStorage, то где хранить токены пользователя?
Некоторые добавляют: JWT нужно хранить внутри httpOnly cookie - особого типа cookie, который отправляется только в HTTP-запросах к серверу и никогда не доступен (как для чтения, так и для записи) из JavaScript, запущенного в браузере.
Хорошая идея. Смотрим раздел использования HTTP cookies в MDN, чтобы узнать, что такое httpOnly cookie. httpOnly - это атрибут, добавляемый к cookies, который делает их недоступными на стороне клиента.
Хорошо. Как хранить JWT в куках httpOnly? Поиск в Google выдал эту статью Райана Ченки.
Он говорит, что существует два варианта безопасного хранения JWT:
- Память браузера (состояние React) - супербезопасно. Однако, если пользователь обновит браузер, JWT будет потерян, и вход потребуется снова. Не лучший пользовательский опыт.
- httpOnly cookie.
Конечная точка логина должена сгенерировать JWT и сохранить его в cookie:
res.cookie('token', token, { httpOnly: true });
token
предварительно генерируется в коде библиотекой (например jsonwebtoken
). httpOnly: true
- это как раз то, что делает cookie невидимым для клиента. Когда httpOnly
был установлен на false
, мы можем получить доступ к содержимому куки в консоли с помощью document.cookie
. Установка httpOnly: true
предотвращает это.
Проблема в том, что клиент и сервер могут работать на разных портах, к примеру на localhost:3000 и localhost:5000 при разработке. Не существует такого понятия, как кросс-доменные куки - куки могут быть установлены только в том же домене, что и сервер. Как это обойти?
Если создать своего клиента с помощью Create-React-App, то мы можем использовать проксирование. Можно добавить например "proxy": "http://localhost:4000"
в package.json, таким образом мы ставим URL для API, к которому необходимо делать вызовы. Теперь путь будет относительным (т.е. вместо ${baseAPI}/auth/login
мы используем/auth/login
), этого достаточно.
После этого ответы от сервера станут приходить с заголовком Set-cookie
, и можно будет увидеть Cookie в Chrome Dev Tools.
Как говорит Райан, Теперь, когда JWT находится в cookie, он будет автоматически отправляться API при любом обращении к нему. Так браузер ведет себя по умолчанию.
Следующий вопрос: как защитить маршруты, когда маркер хранится в cookie?
По определению, куки httpOnly
не могут быть доступны клиенту, так как же мы можем защитить маршруты после того, как пользователь вошел в систему? Кто-то предложил идею в этом вопросе на StackOverflow. По сути, вы продолжаете генерировать httpOnly: true
cookie, содержащий токен, и генерируете еще один, httpOnly: false
, на этот раз без конфиденциальной информации, который только информирует о том, что пользователь вошел в систему. Следуя этой логике, вам даже не нужен cookie: получив успешный ответ API на вход, вы можете сохранить loggedIn: true
в localStorage
.
Итак, вы можете проверить куки httpOnly: false
(или localStorage) и определить, вошел ли пользователь в систему или нет. Если нет, перенаправить на страницу авторизации.
А как получить доступ к cookie в React?
Конечно, есть два пути: воспользоваться библиотекой (например js-cookie) или сделать это самостоятельно.
Ок, нам необходимо защитить маршруты, чтобы доступ к ним могли получить только те пользователи, которые вошли в систему (у которых cookie isLoggedIn
имеет значение true
).
Думаю, многие понимают как создать <PrivateRoute />
, можно почитать статью Тайлера МакГинниса, она идеально подходит в качестве пошагового руководства.
const PrivateRoute = ({ render: Component, ...rest }) => ( <Route {...rest} render={(props) => Cookie.get('isLoggedIn') === 'true' ? ( <Component {...props} /> ) : ( <Redirect to='/login' /> ) } /> );
Можно использовать PrivateRoute
для защиты своего роута:
<PrivateRoute exact path='/' render={(props) => ( <AddUrl {...props} shortUrl={shortUrl} setShortUrl={setShortUrl} /> )} />
render: Component
изнально был component: Component
, потому что именно такой синтаксис пишут в туториалах. Однако это не работает, и может ввести в ступор. Можно прочитать этот ответ и понять, что ключ должен соответствовать атрибуту, который вы передаете в Route. Так, если вы передаете component={WHATEVER_COMPONENT_NAME}
, то Private Route должен иметь component: Component
. Если мой маршрут имеетrender={bla bla bla}
, приватный маршрут должен иметь render: Component
.
Следующий вопрос: как выйти из системы?
Поскольку cookie с маркером имеет значение httpOnly: true
, он не будет доступен на клиенте, поэтому нам нужно, чтобы сервер удалил его. Как кто-то указал в этом вопросе на StackOverflow, вы можете обновить cookie на стороне сервера, просто оставив его пустым или забив его каким-нибудь мусором, также стоит не забыть поставить прошедшую дату экспирации.
В итоге, получаем на сервере что-то вроде:
res.cookie('token', 'deleted', { httpOnly: true }); res.cookie('isLoggedIn', false);
Боюсь, что да... Райан говорит о добавлении защиты от подделки межсайтовых запросов и добавлении анти-CSRF токена.
Что такое атака Cross Site Request Forgery
Здесь можно почитать на эту тему. Смысл таков: злоумышленник создает HTTP-запрос к некоторому сервису (например, к вашему счету в ebank), который спрятан внутри вредоносного сайта. Вас могут обманом заманить на этот сайт, и этот сайт может отправить HTTP-запрос. Суть атаки в том, что, поскольку вы аутентифицированы, вместе с запросом передаются куки аутентификации, и для сервера запрос является валидным.
Как известно, существуют меры защиты, которые должен предпринять сервер для защиты от этих атак: строгая политика CORS (при необходимости разрешая запросы только от определенных источников) и CSRF-токены.
Что такое токен CSRF
Здесь и здесь можно прочитать про этот токен. CSRF-токен на стороне сервера можно сгенерировать с помощью библиотеки csurf, и, после передачи клиенту в поле ответа, он устанавливается в качестве заголовка к каждому AJAX-запросу, который вы делаете к вашему серверу. Вы должны генерировать маркер как можно раньше в вашем приложении на сервере, потому что проверка CSRF происходит в middleware, которое размещается как можно раньше на сервере. Рекомендуют делать это следующим образом:
useEffect
в React App обращается к серверу для получения CSRF-токена по определенному пути. Этот токен генерируется библиотекой (напримерcsurf
).- Токен возвращается в поле ответа, а секретная часть, для проверки того что токен не был подделан, возвращается в виде cookie. Первый должен быть установлен в качестве заголовка к каждому последующему AJAX-запросу с помощью
axios.default.headers.post['X-CSRF-Token]
'. Второй должен быть возвращен клиенту какhttpOnly
иsecurecookie
. Отправляется это дело в заголовкеSet-cookie
, и куки должны быть добавлены в каждый последующий запрос клиента.
Вроде все, но есть еще одна проблема. Предлагается создать конечную точку (endpoint), которая отправляет токен клиенту. Однако, если вы перейдете на страницу npm библиотеки csurf, там есть заголовок со ссылкой на эту страницу: Понимание CSRF, раздел о CSRF-токенах. Они говорят Не создавайте маршрут /csrf только для того, чтобы получить токен, и особенно не поддерживайте CORS на этом маршруте!
Очевидно, многие задаются этим вопросом - см. примеры здесь или здесь. У каждого программиста, похоже, есть свой способ. Многие согласны с тем, что не существует идеального решения.
Есть интересная статья Харлина Манна, где он объясняет, как снизить риски при использовании куки для хранения JWT, советую почитать.
Однако это еще не все!
И Райан, и Харлин говорят, что самым безопасным методом является хранение JWT в памяти и использование маркеров обновления.
Если есть возможность, храните JWT в состоянии приложения и обновляйте их либо через центральный сервер авторизации, либо с помощью refresh-токен в cookie.
Источник: https://dev.to/petrussola/today-s-rabbit-hole-jwts-in-httponly-cookies-csrf-tokens-secrets-more-1jbp