Перейти к содержимому

Как сохранить токен в куки

  • автор:

Защита JWT для аутентификации, httpOnly cookies, CSRF-токены

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

localStorage действительно небезопасен. Но если не в localStorage, то где хранить токены пользователя?

Некоторые добавляют: JWT нужно хранить внутри httpOnly cookie — особого типа cookie, который отправляется только в HTTP-запросах к серверу и никогда не доступен (как для чтения, так и для записи) из JavaScript, запущенного в браузере.

Хорошая идея. Смотрим раздел использования HTTP cookies в MDN , чтобы узнать, что такое httpOnly cookie. httpOnly — это атрибут, добавляемый к cookies, который делает их недоступными на стороне клиента.

Хорошо. Как хранить JWT в куках httpOnly? Поиск в Google выдал эту статью Райана Ченки .

Он говорит, что существует два варианта безопасного хранения JWT:

  1. Память браузера (состояние React) — супербезопасно. Однако, если пользователь обновит браузер, JWT будет потерян, и вход потребуется снова. Не лучший пользовательский опыт.
  2. 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, к которому необходимо делать вызовы. Теперь путь будет относительным (т.е. вместо $/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 ).

Думаю, многие понимают как создать , можно почитать статью Тайлера МакГинниса , она идеально подходит в качестве пошагового руководства.

const PrivateRoute = (< render: Component, . rest >) => ( render= Cookie.get('isLoggedIn') === 'true' ? ( /> ) : ( ) > /> );

Можно использовать PrivateRoute для защиты своего роута:

 ( shortUrl= setShortUrl= /> )> />

render: Component изнально был component: Component , потому что именно такой синтаксис пишут в туториалах. Однако это не работает, и может ввести в ступор. Можно прочитать этот ответ и понять, что ключ должен соответствовать атрибуту, который вы передаете в Route. Так, если вы передаете component= , то Private Route должен иметь component: Component . Если мой маршрут имеет render= , приватный маршрут должен иметь 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, которое размещается как можно раньше на сервере. Рекомендуют делать это следующим образом:

  1. useEffect в React App обращается к серверу для получения CSRF-токена по определенному пути. Этот токен генерируется библиотекой (например csurf ).
  2. Токен возвращается в поле ответа, а секретная часть, для проверки того что токен не был подделан, возвращается в виде 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.

LocalStorage vs Cookies: все, что нужно знать о безопасном хранении токенов JWT во Front-End

В переводе статьи LocalStorage vs Cookies: All You Need To Know About Storing JWT Tokens Securely in The Front-End, опубликованном webdevblog.ru, даются рекомендации по хранению и использованию JWT-токенов. Если кратко резюмировать статью, то в ней рекомендуется полученные от бекенда access токены хранить в приложение, а refresh токены — в куках. А вот если хотите узнать, почему именно такие рекомендации — читайте статью.

Токены JWT замечательны, но как их надежно хранить на фронтенде? Мы рассмотрим плюсы и минусы LocalStorage и cookie для хранения JWT.

Краткий обзор Access token и Refresh token

Токены доступа (Access token) обычно являются недолговечными токенами JWT, подписанными сервером и включенными в каждый HTTP-запрос к серверу для авторизации запроса.

Токены обновления (Refresh token) обычно представляют собой долговечные зашифрованные токенами JWT, хранящиеся в базе данных, которые используются для получения нового токена доступа по истечении срока его действия.

Где лучше хранить свои токены?

Существует два распространенных способа хранения токенов: в localStorage и в cookie. Есть много споров о том, какой из них лучше, но большинство людей склоняются к cookie из-за большей безопасности.

Давайте рассмотрим это сравнение чуть более подробнее.

Local Storage

Плюсы: Это удобно.

  • Это чистый JavaScript и с ним удобней работать. Если у вас нет бэкэнда и вы полагаетесь на стороннее API, вы не всегда сможете установить определенные cookie для вашего приложения.
  • Работает с API-интерфейсами, которые требуют, чтобы вы поместили свой токен доступа в заголовок запроса, типа такого Authorization Bearer $ .

Минусы: Такой способ уязвим к XSS атакам.

Атака XSS происходит, когда злоумышленник может запустить свой скрипт JavaScript на вашем сайте во время посещения его другим пользователем. Это означает, что злоумышленник сможет получит доступ к токену доступа, который вы сохранили в localStorage.

Cookies

Плюсы: Файл cookie может быть не доступен через JavaScript, поэтому он не так уязвим для атак XSS, как localStorage.

  • Если вы используете флаги httpOnly, то cookie будут не доступны из JavaScript. Это означает, что даже если злоумышленник сможет запустить JS на сайте, он не сможет прочитать ваш токен доступа.
  • Cookie автоматически отправляется в каждом HTTP-запросе на ваш сервер.

Минусы: В зависимости от варианта использования иногда вы не сможете хранить свои токены в файлах cookie..

  • Размер файлов cookie ограничен 4 КБ, поэтому, если вы используете большие токены JWT, сохранение в файле cookie станет не возможным.
  • Существуют сценарии, когда вы не можете использовать cookie напрямую для доступа к серверному API. Например когда требуется наличие токена в заголовке запроса.

Кратко о XSS атаках

Итак мы уже сказали что local storage уязвим, так как он легко доступен с помощью JavaScript, и злоумышленник может получить access token и использовать его. Однако, хотя cookie с httpOnly недоступны из JavaScript, это не означает, что с помощью cookie вы полностью защищены от XSS атак.

Если злоумышленник может запустить JavaScript в вашем приложении, он все равно сможет отправить HTTP-запрос на ваш сервер получив таким образом все токены. Но для злоумышленника это менее удобно, потому что он не сможет прочитать содержимое токена, хотя это и не всегда нужно.

Cookies и CSRF Атаки

CSRF Attack — это атака, которая позволяет сделать запрос от имени пользователя но без его ведома. Например, если веб-сайт принимает запрос на изменение электронной почты через такой запрос:POST /email/change HTTP/1.1Host: site.comContent-Type: application/x-www-form-urlencodedContent-Length: 50Cookie: session=abcdefghijklmnopqrstuemail=myemail.example.com

То злоумышленник может легко создать форму на вредоносном веб-сайте, которая отправит POST запрос на https://site.com/email/change со скрытым полем электронной почты, и cookie с токенами будут автоматически включены в этот запрос.

Однако это можно легко исправить, используя флаг cookie sameSite и добавив anti-CSRF token.

Вывод

Хотя куки все еще имеют некоторые уязвимости, они предпочтительнее по сравнению с localStorage, когда их применение возможно. И вот почему?

  • Как localStorage, так и cookie уязвимы для атак XSS, но злоумышленнику сложнее выполнить эту атаку, когда вы используете cookie с httpOnly.
  • Cookie уязвимы для атак CSRF, но их вероятность можно уменьшить с помощью флага sameSite и токенов защиты от CSRF (anti-CSRF tokens).
  • Вы все еще можете использовать cookie, даже если вам нужно использовать заголовок Authorization: Bearer или ваш JWT больше 4 КБ. Это также согласуется с рекомендацией сообщества OWASP:

Итак, как мне использовать куки для сохранения моих токенов OAuth 2.0?

Напомним, вот несколько способов хранения ваших токенов:

  • Вариант 1: Хранение Access Token в localStorage: но это выбор уязвим к XSS атакам.
  • Вариант 2: Хранение Access Token в cookie с httpOnly: но этот выбор будет уязвим к CSRF атакам, хотя его можно уменьшить, что лучше чем XSS.
  • Вариант 3: Хранение Refresh Token в cookie c httpOnly: это будет безопасно от CSRF атак, и немного лучше от XSS.

Мы рассмотрим, Вариант 3, так как он предлагает лучшие преимущества из трех вариантов.

Сохранять Access Token в памяти приложения а Refresh Token в cookie.

Шаг 1: Пользователь получает Access Token и Refresh Token когда проходит аутентификацию.

После аутентификации пользователя Сервер авторизации отдает пользователю access_token и refresh_token. Access_token может быть включен в тело ответа, refresh_token должен быть включен только в cookie.

Свойства токена в cookie:

  • Используйте флаг httpOnly, чтобы из JavaScript не было возможности прочитать его.
  • Используйте флаг secure = true, чтобы его можно было отправлять только через HTTPS.
  • По возможности используйте флаг SameSite = strict, чтобы предотвратить CSRF атаки. Но это можно использовать только в том случае, если у сервера авторизации тот же домен, что и у клиентского приложения. Если это не так, ваш Сервер авторизации должен установить нужные заголовки CORS в серверной части или использовать другие методы, чтобы гарантировать, что запрос на обновление токена может быть выполнен только авторизованными веб-сайтами.

Шаг 2: Сохраните Access Token в памяти

Хранение токена в памяти означает, что вы поместили этот токен доступа в соответствующую переменную в своем приложение. Да, это означает, что access token исчезнет, если пользователь переключит вкладки или обновит страницу. Вот зачем у нас есть refresh token.

Шаг 3: Обновляйте access token, используя refresh token

Когда access token token пропадет или срок его действия истечет, используйте серверное API для получения нового токена используя refresh token, который был сохранен в cookie в шаге 1, и будет включен в каждый запрос (то есть сервер получит его через cookie ). После получения нового access token сможете использовать его для своих запросов API.

Это означает, что ваш токен JWT может быть больше 4 КБ, и вы также можете поместить его в заголовок авторизации.

Заключение

Если вы создаете механизм авторизации для своего веб-сайта или мобильного приложения, эти статьи могут помочь:

  • What On Earth Is OAuth? A Super Simple Intro to OAuth 2.0, Access Tokens, and How to Implement it in your Site
  • Passwordless Login with Email and JSON Web Token (JWT) Authentication using Next.js
  • Here’s How to Integrate Cotter’s Magic Link to Your Webflow Site in Less Than 15 minutes!

JWT in Cookies

Highly recommended using JWT in cookies, if your frontend interacts with the backend, your frontend may be storing JWT in the browser localStorage or sessionStorage. There is nothing wrong with this, but if you have any sort of XSS vulnerability on your site, an attacker will be able to trivially steal your tokens. If you want some additional security on your site, you can save your JWT in an httponly cookies. Which keeps javascript cannot be able to access the cookies.

Here is a basic example of how to store JWT in cookies:

You can also create cookies or unset cookies when returning a Response directly in your code. To do that, you can create a response then set the response in set cookies or unset cookies

. response = JSONResponse(content="msg":"Successfully login">) # Set the JWT and CSRF double submit cookies in the response Authorize.set_access_cookies(access_token,response) Authorize.set_refresh_cookies(refresh_token,response) return response 
from fastapi import FastAPI, HTTPException, Depends, Request from fastapi.responses import JSONResponse from fastapi_jwt_auth import AuthJWT from fastapi_jwt_auth.exceptions import AuthJWTException from pydantic import BaseModel """ Note: This is just a basic example how to enable cookies. This is vulnerable to CSRF attacks, and should not be used this example. """ app = FastAPI() class User(BaseModel): username: str password: str class Settings(BaseModel): authjwt_secret_key: str = "secret" # Configure application to store and get JWT from cookies authjwt_token_location: set = "cookies">  # Disable CSRF Protection for this example. default is True authjwt_cookie_csrf_protect: bool = False  @AuthJWT.load_config def get_config(): return Settings() @app.exception_handler(AuthJWTException) def authjwt_exception_handler(request: Request, exc: AuthJWTException): return JSONResponse( status_code=exc.status_code, content="detail": exc.message> ) @app.post('/login') def login(user: User, Authorize: AuthJWT = Depends()): if user.username != "test" or user.password != "test": raise HTTPException(status_code=401,detail="Bad username or password") # Create the tokens and passing to set_access_cookies or set_refresh_cookies access_token = Authorize.create_access_token(subject=user.username) refresh_token = Authorize.create_refresh_token(subject=user.username) # Set the JWT cookies in the response Authorize.set_access_cookies(access_token)  Authorize.set_refresh_cookies(refresh_token)  return "msg":"Successfully login"> @app.post('/refresh') def refresh(Authorize: AuthJWT = Depends()): Authorize.jwt_refresh_token_required() current_user = Authorize.get_jwt_subject() new_access_token = Authorize.create_access_token(subject=current_user) # Set the JWT cookies in the response Authorize.set_access_cookies(new_access_token)  return "msg":"The token has been refresh"> @app.delete('/logout') def logout(Authorize: AuthJWT = Depends()): """ Because the JWT are stored in an httponly cookie now, we cannot log the user out by simply deleting the cookies in the frontend. We need the backend to send us a response to delete the cookies. """ Authorize.jwt_required() Authorize.unset_jwt_cookies()  return "msg":"Successfully logout"> @app.get('/protected') def protected(Authorize: AuthJWT = Depends()): """ We do not need to make any changes to our protected endpoints. They will all still function the exact same as they do when sending the JWT in via a headers instead of a cookies """ Authorize.jwt_required() current_user = Authorize.get_jwt_subject() return "user": current_user> 

This isn’t the full story. However now we can keep our cookies from being stolen via XSS attacks, but session cookies vulnerable to CSRF attacks. To combat CSRF attacks we are going to use a technique called double submit cookie pattern. Double submitting cookies is defined as sending a random value in both a cookie and as a request parameter, with the server verifying if the cookie value and request value are equal.

This tokens is saved in a cookie with httponly set to True, so it cannot be accessed via javascript. We will then create a secondary cookie that contains an only random string, but has httponly set to False so that it can be accessed via javascript running on your website.

Now in order to access a protected endpoint, you will need to add a custom header that contains the random string in it, and if that header doesn’t exist or it doesn’t match the string that is stored in the JWT, the requester will be kicked out as unauthorized.

To break this down, if an attacker attempts to perform a CSRF attack they will send the JWT (via cookie) to protected endpoint, but without the random string in the request headers, they won’t be able to access the endpoint. They cannot access the random string unless they can run javascript on your website likely via an XSS attack, and if they are able to perform an XSS attack, they will not be able to steal the actual access and refresh JWT, as javascript is still not able to access those httponly cookies.

No system is safe. If an attacker can perform an XSS attack they can still access protected endpoints from people who visit your site. However, it is better than if they were able to steal the access and refresh tokens from local/session storage, and use them whenever they wanted.

Here is an example of using cookies with CSRF protection:

from fastapi import FastAPI, HTTPException, Depends, Request from fastapi.responses import JSONResponse from fastapi_jwt_auth import AuthJWT from fastapi_jwt_auth.exceptions import AuthJWTException from pydantic import BaseModel """ By default, the CRSF cookies will be called csrf_access_token and csrf_refresh_token, and in protected endpoints we will look for the CSRF token in the 'X-CSRF-Token' headers. only certain methods should define CSRF token in headers default is ('POST','PUT','PATCH','DELETE') """ app = FastAPI() class User(BaseModel): username: str password: str class Settings(BaseModel): authjwt_secret_key: str = "secret" # Configure application to store and get JWT from cookies authjwt_token_location: set = "cookies">  # Only allow JWT cookies to be sent over https authjwt_cookie_secure: bool = False  # Enable csrf double submit protection. default is True authjwt_cookie_csrf_protect: bool = True  # Change to 'lax' in production to make your website more secure from CSRF Attacks, default is None # authjwt_cookie_samesite: str = 'lax'  @AuthJWT.load_config def get_config(): return Settings() @app.exception_handler(AuthJWTException) def authjwt_exception_handler(request: Request, exc: AuthJWTException): return JSONResponse( status_code=exc.status_code, content="detail": exc.message> ) @app.post('/login') def login(user: User, Authorize: AuthJWT = Depends()): """ With authjwt_cookie_csrf_protect set to True, set_access_cookies() and set_refresh_cookies() will now also set the non-httponly CSRF cookies """ if user.username != "test" or user.password != "test": raise HTTPException(status_code=401,detail="Bad username or password") # Create the tokens and passing to set_access_cookies or set_refresh_cookies access_token = Authorize.create_access_token(subject=user.username) refresh_token = Authorize.create_refresh_token(subject=user.username) # Set the JWT and CSRF double submit cookies in the response Authorize.set_access_cookies(access_token)  Authorize.set_refresh_cookies(refresh_token)  return "msg":"Successfully login"> @app.post('/refresh') def refresh(Authorize: AuthJWT = Depends()): Authorize.jwt_refresh_token_required() current_user = Authorize.get_jwt_subject() new_access_token = Authorize.create_access_token(subject=current_user) # Set the JWT and CSRF double submit cookies in the response Authorize.set_access_cookies(new_access_token)  return "msg":"The token has been refresh"> @app.delete('/logout') def logout(Authorize: AuthJWT = Depends()): """ Because the JWT are stored in an httponly cookie now, we cannot log the user out by simply deleting the cookie in the frontend. We need the backend to send us a response to delete the cookies. """ Authorize.jwt_required() Authorize.unset_jwt_cookies()  return "msg":"Successfully logout"> @app.get('/protected') def protected(Authorize: AuthJWT = Depends()): Authorize.jwt_required() current_user = Authorize.get_jwt_subject() return "user": current_user> 

Все еще работаете с access token на клиенте? Тогда мы идем к вам 23.01.2023 20:46

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

В статье рассмотрим причины необходимости работы с токеном на клиенте веб-приложений, узнаем , что лучше для хранения токена: localStorage, sessionStorage или cookie без флага HttpOnly (спойлер, ничего из этого), а также посмотрим на меры воздействия, которые можно использовать для снижения риска утечки токена посредством различных уязвимостей.

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

e71279387ba79d9aa010a01ed519333d.png

Введение

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

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

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

Access token (токеном доступа) здесь и далее я буду называть некую строку, используемою для доступа к защищенному ресурсу (protected resource). Сам токен может иметь совершенно разные форматы и имплементации.

Также в статье будем оперировать понятиями XSS и CSRF.

Проблематика

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

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

Кто-то скажет здесь:

Ну так это пример в вакууме, мы делаем наши приложения без XSS-уязвимостей, у нас есть %framework_name% или %library_name%, там это все учтено. Зачем нам что-то додумывать, если мы сфокусируемся на том, чтобы не допускать такие уязвимости вообще?

Звучит резонно, не правда ли? Однако реальность, к сожалению, не так проста. Практика и подходы показывают нам, что гарантию такую дать в большинстве случаев почти нереально. К тому же запросто может быть случай, где угрозы безопасности нашим веб-приложениям приходят совсем из других мест: например, токен мы положили в какую-нибудь wildcard и не-HttpOnly-cookie, обезопасили свое приложение от XSS до зубов, но вот незадача, на одном из поддоменов обнаружился старый дырявый проект, который свел на нет все наши старания.

Чем опасен украденный access token? С ним злоумышленник сможет выполнять запросы к ресурсам API от нашего лица или же попросту имперсонализироваться под нашей учетной записью у себя в браузере.

Примеры (или набор вредных советов)

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

Access token в localStorage

В данном приложении access token после получения с бэкенда помещается в локальное хранилище localStorage.

c78721d62268973d92a85fa6c2186aeb.png

В таком случае при наличии XSS-уязвимости токен может быть получен злоумышленником. Однако у токена указан срок жизни — 15 минут. Допустим ли риск того, что в течение N (N .%sitename%.com . Значит, данная cookie является wildcard, то есть будет доступна на всех поддоменах, удовлетворяющих такому условию.

  • Во-первых, сам по себе access token, живущий год, и при этом доступный на клиенте является серьезной угрозой. При краже злоумышленником токена посредством XSS он будет действовать еще очень долго.
  • Во-вторых, здесь токен помещен в cookie, которая доступна на всех поддоменах сайта. Это означает, что даже если в самом основном приложении XSS злоумышленник и не найдет, то среди содержимого наших поддоменов запросто может оказаться уязвимый сервис. Поиск поддоменов — один из типовых приемов, используемых при пентесте. На них может найтись много интересного: например, какой-то старый сайт или тестовый проект — потенциально уязвимый сервис. Также встречаются и атаки вида subdomain takeover (захват поддомена). В этом случае тоже злоумышленник сможет произвести кражу токена аналогичным способом.

Примечательно также, что подобную реализацию я встречал не единожды и, как видно, она несет наибольшие риски из всех перечисленных. Возможно, такое использование wildcard сookie где-то предполагалось для «бесшовной» аутентификации между поддоменами, однако хочется предостеречь от бездумного использования такого подхода.

Почему так, зачем нужно работать с токеном на клиенте?

Логичный вопрос:, а зачем вообще нам тогда делать токен доступным на клиенте и работать с ним там? Почему бы не положить его просто в сессионную hardened-cookie? Кроме допущенных недостатков проектирования, я вижу для этого несколько предпосылок.

1. Использование stateless-токенов

Такой подход еще иногда называют «token-based authentication» (что на мой взгляд не совсем корректно, ведь токен у нас может быть и вполне stateful). Когда мы используем слово «сессия», мы скорее всего подразумеваем stateful-вариант. То есть на сервере хранится какая-то информация о нашей сессии, которая проверяется при обращении ресурсу.

В случае со stateless-токенами картина иная. Данный токен самодостаточен сам по себе и предполагает содержание в себе всей информации, необходимой для авторизации. Обычно для этого используют JWT-токены, которые имеют стандартную структуру.

Пример структуры JWT-токена

Пример структуры JWT-токена

За счет наличия блока signature токен может подписывать свое содержимое, и валидность подписи может быть проверена при получении запроса с данным токеном: приватным ключом при симметричном или публичным ключом при ассиметричном шифровании. Так валидация JWT может происходить без участия выдавшего его сервера и, соответственно, без необходимости обращения в БД для каждой проверки.

Подход позволяет как раз уйти от хранения токенов на сервере (по крайней мере, в его «ванильной» имплементации), отдавать такой JWT-токен после аутентификации и хранить его уже на том, что для нас является клиентом.

2. Использование OpenID Connect (OIDC) для аутентификации в своем же приложении

Не будем путать OpenID Connect с OAuth 2.0. OIDC расширяет возможности OAuth 2.0 и представляет собой протокол аутентификации, в то время как OAuth 2.0 является протоколом авторизации.

Причину я вижу в том, что by default реализации метода получения токена в OIDC возвращают токен в application/json теле ответа с типом Bearer. Пример ответа такого метода какой-нибудь типовой имплементации OIDC:

< "access_token": "", "token_type": "Bearer", "expires_in": 3600, "scope": "openid" >

Тип токена Bearer предполагается к использованию самой спецификацией OIDC. Следствие кроется в самом названии типа. Bearer-токен — токен на предъявителя.

Соответственно, кто его предъявит, тот и является авторизованным пользователем. Поскольку токен доступен доступен на клиенте, это и используется: обычно клиент отправляет к серверу запросы с заголовком Authorization: Bearer .

Ну и поскольку мы получаем токен в JSON-ответе от сервера, то на клиенте у нас не так много опций, куда этот токен можно безопасно положить.

Пару слов про OIDC и PKCE

Отдельное внимание хочу уделить использованию механизма PKCE в authorization code flow. PKCE рекомендован к использованию для public clients — клиентов, которые не имеют возможности конфиденциального хранения client secret, таким как SPA, однако применяется также и для confidential clients. Важно понимать, что PKCE не предоставляет абсолютно никакой защиты для токена на клиенте, он вообще не про это. Корректно реализованный PKCE дает возможность убедиться, что за получением токена обращается тот же субъект, который обращался за получением authorization code и все. Помните про это и не смешивайте эти понятия.

3. Использование Single-page applications (SPA) без бэкенда

Также это касается и использования Single-page applications (SPA) без бэкенда. Не используя бэкенд, разработчики вынуждены искать способы работы с токеном на клиенте.

Что можно сделать, варианты мер и решений

В интернете раньше были популярны споры, где лучше хранить токен для работы с ним: в localStorage, sessionStorage или сookies (естественно, без HttpOnly). На самом деле, с точки зрения безопасности разницы практически нет. И вот почему:

localStorage

sessionStorage

Cookies без флага HttpOnly

Доступно с клиента

Привязка к конкретному домену

Да (и шире, см. wildcard-случаи)

Контекст

Синхронизируется между вкладками

Ограничен пределами вкладки

Синхронизируется между вкладками

Персистентность

Сохраняет состояние после закрытия браузера

Сохраняет состояние при обновлении вкладки, теряет при ее закрытии

Сохраняет состояние после закрытия браузера

А как же IndexedDB?

С рассматриваемых в сравнении точек зрения IndexedDB не отличается от localStorage, за исключением одного нюанса: доступ к ней есть также и у service workers.

Как видно из сравнения выше, все три приведенных способа одинаково уязвимы при возможности выполнения вредоносного кода на клиенте. Что доступно из кода разработчику, доступно из кода и злоумышленнику.

А что же тогда делать? Как и везде, серебряной пули не существует, разные подходы имеют свои особенности, свои pros и cons — достоинства и недостатки. И здесь мы попробуем рассмотреть такие подходы независимо, тогда как выбор конкретного — индивидуальная задача, которую необходимо решить при проектировании.

Подход 1. Не работать с токеном на клиенте вообще

Именно так, первый и самый простой способ избежать рисков — отказаться от работы с токеном на клиенте. Использовать stateful-подход к аутентификации вместо stateless. Тогда мы можем использовать сессионную cookie для хранения нашего токена. Важно помнить, что для такой cookie потребуется корректная установка атрибутов HttpOnly , Secure , SameSite , Path . Также подчеркну, что не рекомендуется бездумно использовать wildcard cookies (причины были рассмотрены выше), поэтому внимание стоить уделить и атрибуту Domain .

Однако есть нюанс. В таком случае, поскольку данная cookie будет отправляться на сервер в запросах к указанным Domain и Path , необходимо предусмотреть защиту от CSRF-атаки. Сделать это можно, например, согласно рекомендациям OWASP.

Такой подход лишает нас возможностей, предоставляемых stateless-токенами, однако он делает наши риски более прозрачными и может быть проще в реализации.

Подход 2. Проксирующий бэкенд

А что если мы хотим оставить возможность работы со stateless-токенами? Здесь возможно использование middleware-слоя, который будет обеспечивать безопасность хранения токена.

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

7bc307f781158ea79b372084331f3280.png

Пару слов про Authorization server

По RFC 6749 под Authorization server понимается сервер, выпускающий access-токены для клиента после успешной аутентификации. Поэтому в данном случае Authorization server выполняет аутентификацию.

Authorization server и Resource server могут быть различными сервисами или одним и тем же, их реализация зависит от выбранной архитектуры приложения.

Имеем все те проблемы, о которых упоминали выше. Теперь добавим наш промежуточный слой:

999637d77d78bcc6059d3cd8b876b4d6.png

Выглядит сложнее, давайте разбираться. Здесь Backend proxy уже выступает не public-, а confidential-клиентом (RFC 6749, п. 2.1), и должен выполнять обращение за получением токена со своими client id и client secret.

В процессе аутентификации и получения токена мы обращаемся не напрямую к Authorization server, а через Backend proxy, что показано в [1]. Соответственно и access token также будет получать он ([4]). Затем сервер генерирует некую cookie (подробнее рассмотрим ниже) c флагом HttpOnly, которую и отправляет на клиент ([6]). Клиент далее в [7] обращается к API ресурса, но делает это также не напрямую (поскольку он не владеет токеном доступа), а через наш Backend proxy. Здесь происходит аутентификация клиента и «размен» значения из cookie на легитимный access_token, с которым идет обращение к Resource server ([9]), а полученный ответ проксируется обратно на клиент ([12]).

Встает вопрос: какую cookie может выдавать наш Backend proxy? Здесь вижу несколько подходов.

  1. Обыкновенная сессионная cookie, которая идет в пару полученному токену. В таком случае в нашем компоненте нам придется реализовать работу с сессиями и хранить их. Однако так мы заодно получим способ управления инвалидацией доступа.
  2. Шифрование полученных от Authorization server значений. В таком случае мы избегаем необходимости хранить сессию у себя. При получении запроса на обращение к ресурсу мы производим расшифровку значения из cookie (ключ для этого у нас есть) и проксируем запрос далее.

Сроком действия данных cookie также необходимо управлять самостоятельно, также необходимо помнить про установку значений других их атрибутов. Отдельно отмечу, что такая реализация несет еще ряд тонкостей, таких как подбор времени жизни access_token, организация его кэширования в Backend proxy, механизм обновления access_token и т.д. Вследствие наличия и так большого объема информации, оставим эти вопросы за рамками данной статьи.

Подобный подход может быть применим и к SPA, поскольку делая Single page application совсем не обязательно полностью отказываться от бэкенда, такая тонкая прослойка может быть вполне используема. В микросервисной архитектуре роль Backend proxy может выполнять API gateway или Backend-for-frontend (BFF).

Итак, данный подход предоставляет нам возможность защититься от кражи токена через XSS, однако делает возможной CSRF-атаку, поэтому меры, по защите от нее также должны быть применены.

Подход 3. Добавление пользовательского контекста в токен

До этого мы использовали подходы только с HttpOnly cookies, но что если есть и иной путь? Добавление пользовательского контекста в токен предполагает использование вместе как HttpOnly cookie, так и части, доступной на клиенте. Метод проще всего объясним на примере JWT-токена.

Пользовательский контекст может состоять из следующей информации:

  • Случайная строка, сгенерированная сервисом в процессе аутентификации. Передается на клиент в HttpOnly cookie (помним и про другие атрибуты).
  • SHA256-хэш от случайной строки, помещенный в payload JWT-токена.

Очень упрощенно это можно изобразить так:

9f17d243a9c1ee5b65c4c38a737773ef.png

Таким образом для авторизации, помимо проверки JWT-токена, необходимо еще и сравнение хэша от случайной строки, полученной в hardened-cookie, с хэшированным значением в самом токене. Токен при этом может быть сохранен как в сookies, так и в localStorage или sessionStorage — сути это не меняет, поскольку токен сам по себе становится недостаточным для доступа к Resource server.

2b4462bf6d228e7dbe4a2e26cb69797a.png

Существует также еще интересная вариация, которую я тоже отнесу к данному подходу из-за схожести исполнения — Two Cookie JWT Approach. Здесь мы аналогично используем HttpOnly и не-HttpOnly cookie, получаемые с сервера, но принцип разделения информации несколько отличается.

В HttpOnly cookie здесь мы помещаем подпись JWT-токена, в то время как header и payload находятся в доступной на клиенте cookie. Тогда наше обращение к API будет выглядеть так:

При этом важно не забыть о грамотном выставлении атрибута Max-Age у cookies. Таким образом, авторизацию мы все еще выполняем на основе проверки JWT-токена, однако полная его «версия» может быть получена только путем совмещения двух частей, что снижает последствия эксплуатации XSS-уязвимости, поскольку подпись нам с клиента недоступна. При использовании одних только cookies здесь, как и ранее, тоже следует принять меры защиты от CSRF-атак.

Подход 4. Использование service worker

Service worker — скрипт, который браузер запускает в фоновом режиме, выполняющий роль прокси-сервера для взаимодействия между веб-приложением, браузером и сетью. Service worker запускается в отдельном контексте, работает в отдельном потоке, не имеет доступа к DOM, и соответственно клиент также не имеет доступа к service worker и хранимым там данным. Этой его особенностью мы и воспользуемся, чтобы обезопасить access token от утечки при XSS.

В этом случае service worker отвечает за получение токена от Authorization server и выполнение запросов к Request server. Запросы с клиента в данном случае проксируются service worker, он как бы перехватывает их. Следовательно вызов метода получения токена и сам токен полностью изолированы, поскольку контекст service worker недоступен для прочих JavaScript-контекстов.

af50b0f690ab1cc025b07eca0f236b02.png

Напоминает рассмотренную ранее схему с проксирующим бэкендом, не правда ли? Однако здесь средний слой, выступающий в качестве прокси, мы реализуем не на сервере, а на клиенте.

У использования service worker существует еще одна важная деталь: его регистрация должна происходить в самом начале загрузки клиентской части приложения, в противном случае при эксплуатации XSS злоумышленник может инициировать новый flow аутентификации (до регистрации service worker) и получить токен в обход него.

В данном случае CSRF-атака неприменима, поскольку токен доступен только для service worker, а также исключается возможность кражи токена посредством XSS, однако реализация и эксплуатация такой схемы будут сложнее. Технология поддерживается современными браузерами, но, если требуется поддержка Internet Explorer, то такой подход не подойдет.

Подход 5. Хранение токена в памяти

JavaScript предоставляет возможность хранения полученного значения токена в памяти. Для этого используется имитация приватного свойства класса через «closure variable» — локальные переменные внутри замыканий. Тогда мы можем создать некий token-manager-class, который будет хранить значение токена и выполнять самостоятельно все обращения к Resource server, не допуская доступности токена снаружи.

Такой подход возможен, однако имеет ряд особенностей. Во-первых, значение токена не будет сохраняться после перезагрузки страницы и быть доступным из других вкладок. Во-вторых, злоумышленник может перехватить запрос на клиенте уже после его формирования, например, использовав monkey patching для метода fetch.

Таким образом мы рассмотрели различные способы снижения рисков утечки нашего токена с клиента. Все они могут подходить для использования в каких-то конкретных случаях, все имеют свои особенности. Также вижу возможным и комбинацию нескольких подходов: например, фоллбэк с использования service worker при неудачной его регистрации на другой механизм.

Подчеркну, что мы говорили здесь только про кражу токена, в большинстве случая при наличии XSS злоумышленник все еще сможет отправить легитимные запросы к вашему API непосредственно из браузера жертвы, однако меры для предовтращения завладения токеном все равно важны.

Заключение

Тема безопасности аутентификации и авторизации достаточно обширна, и ее не охватить одной статьей. Здесь постарался рассмотреть вопрос хранения и работы с access token в клиентской части веб-приложений, какие риски он может нести и какие меры для их снижения существуют. Я намеренно использую слово «риски» — поскольку степень угрозы для каждого приложения может быть своя, но понимать ее важно.

Также для интересующихся привожу список статей по теме.

Список релевантного чтива

Помните о безопасности, проверяйте свои приложения и оценивайте риски.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *