Вступление
Идентификация по JWT (JSON Web Token) — это довольно единообразный, согласованный механизм авторизации и аутентификации между сервером и клиентами. Преимущества JWT в том, что он позволяет нам меньше управлять состоянием и хорошо масштабируется. Неудивительно, что авторизация и аутентификация с его помощью все чаще используется в современных веб-приложениях.
При разработке приложений с JWT часто возникает вопрос: где и как рекомендуется хранить токен? Если мы разрабатываем веб-приложение, у нас есть два наиболее распространенных варианта:
- HTML5 Web Storage (localStorage or sessionStorage)
- Cookies
Сравнивая эти способы, можно сказать, что они оба сохраняют значения в браузер клиента, оба довольно просты в использовании и представляют собой обычное хранилище пар ключ-значение. Разница заключается в среде хранения.
Web Storage (localStorage/sessionStorage) доступен через JavaScript в том же домене. Это означает, что любой JavaScript код в вашем приложении имеет доступ к Web Storage, и это порождает уязвимость к cross-site scripting (XSS) атакам. Как механизм хранения Web Storage не предоставляет никаких способов обезопасить свои данные во время хранения и обмена. Мы можем его использовать только для вспомогательных данных, которые хотим сохранить при обновлении (F5) или закрытии вкладки: состояние и номер страницы, фильтры итд.
Токены также могут передаваться через файлы cookie браузера. Файлы cookie, используемые с флагом httpOnly, не подвержены XSS. httpOnly — это флаг для доступа к чтению, записи и удалению cookies только на сервере. Они не будут доступны через JavaScript на клиенте, поэтому клиент не будет знать о токене, а авторизация будет полностью обрабатываться на стороне сервера.
Мы также можем установить secure флаг, чтобы гарантировать, что cookie передается только через HTTPS. Учитывая эти преимущества, мой выбор пал на cookies.
Эта статья описывает подход к реализации авторизации и аутентификации с помощью httpOnly secure cookies + JSON Web Token в ASP.NET Core Web Api в связке со SPA. Рассматривается вариант, при котором сервер и клиент находятся в разных origin.
Настройка локальной среды разработки
Для корректной настройки и отладки взаимоотношений клиента и сервера через HTTPS я настоятельно рекомендую сразу настроить локальную среду разработки так, чтобы и клиент, и сервер имели HTTPS-соединение.
Если этого не сделать сразу, а пытаться выстраивать взаимоотношения без HTTPS-соединения, то в дальнейшем всплывет великое множество мелочей, без которых не будут корректно работать secure cookies и дополнительные secure-policy в продакшене с HTTPS.
Я покажу пример настройки HTTPS на OS Windows 10, сервер — ASP.NET Core, SPA — React.
Настроить HTTPS в ASP.NET Core можно с помощью флага Configure for HTTPS при создании проекта или, если мы не сделали этого при создании, включить соответствующую опцию в Properties.
Чтобы настроить SPA, нужно модифицировать скрипт на «start», проставив ему значение «set HTTPS=true». Моя настройка выглядит следующим образом:
'start': 'set HTTPS=true&&rimraf ./build&&react-scripts start'
Настройку HTTPS для среды разработки на других окружениях советую смотреть на create-react-app.dev/docs/using-https-in-development
Настройка ASP.NET Core сервера
Настройка JWT
В данном случае подойдет самая обычная реализация JWT из документации или любой статьи, с дополнительной настройкой options.RequireHttpsMetadata = true;
так как в нашей среде разработки используется HTTPS:
ConfigureServices
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultSignInScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.RequireHttpsMetadata = true;
options.SaveToken = true;
options.TokenValidationParameters = new TokenValidationParameters
{
// ваш доп. конфиг
};
});
Настройка CORS-политики
Важно: CORS-policy должна содержать AllowCredentials()
. Это нужно, чтобы получить запрос с XMLHttpRequest.withCredentials и отправить cookies обратно клиенту. Подробнее об этом будет написано далее. Остальные опции настраиваются в зависимости от нужд проекта.
Если сервер и клиент находятся на одном origin, то вся конфигурация ниже не нужна.
ConfigureServices
services.AddCors();
Configure
app.UseCors(x => x
.WithOrigins("https://localhost:3000") // путь к нашему SPA клиенту
.AllowCredentials()
.AllowAnyMethod()
.AllowAnyHeader());
Настройка cookie-policy
Принудительно настраиваем cookie-policy на httpOnly и secure.
По возможности устанавливаем MinimumSameSitePolicy = SameSiteMode.Strict;
— это повышает уровень безопасности файлов cookie для типов приложений, которые не полагаются на обработку cross-origin запросов.
Configure
app.UseCookiePolicy(new CookiePolicyOptions
{
MinimumSameSitePolicy = SameSiteMode.Strict,
HttpOnly = HttpOnlyPolicy.Always,
Secure = CookieSecurePolicy.Always
});
Идея безопасного обмена токеном
Эта часть представляет собой концепцию. Мы собираемся сделать две вещи:
- Прокинуть токен в HTTP-запрос с использованием httpOnly и secure флагов.
- Получать и валидировать токены клиентских приложений из HTTP-запроса.
Для этого нам надо:
- Записывать токен в httpOnly cookie при логине и удалять его оттуда при разлогине.
- При наличии токена в cookies подставить токен в HTTP-заголовок каждого последующего запроса.
- Если токена нет в cookies, то заголовок будет пустым, а запрос не будет авторизованным.
Middleware
Основная идея — это реализовать Custom Middleware для вставки токена во входящий HTTP-запрос. После авторизации пользователя мы сохраняем cookie под определенным ключом, например: ".AspNetCore.Application.Id". Я рекомендую задавать название, никак не связанное с авторизацией или токенами, — в этом случае cookie с токеном будут выглядеть как какая-то непримечательная системная константа AspNetCore приложения. Так выше шанс, что злоумышленник увидит много системных переменных и, не разобравшись, какой механизм авторизации используется, пойдет дальше. Конечно, если он не прочитает эту статью и не будет специально высматривать такую константу.
Далее нам нужно вставить этот токен во все последующие входящие HTTP-запросы. Для этого мы напишем несколько строк кода Middleware. Это не что иное, как HTTP-pipeline.
Configure
app.Use(async (context, next) =>
{
var token = context.Request.Cookies[".AspNetCore.Application.Id"];
if (!string.IsNullOrEmpty(token))
context.Request.Headers.Add("Authorization", "Bearer " + token);
await next();
});
app.UseAuthentication();
Мы можем вынести эту логику в отдельный Middleware-сервис, чтобы не засорять Startup.cs, идея от этого не изменится.
Для того чтобы записать значение в cookies, нам достаточно добавить следующую строку в логику авторизации:
if (result.Succeeded)
HttpContext.Response.Cookies.Append(".AspNetCore.Application.Id", token,
new CookieOptions
{
MaxAge = TimeSpan.FromMinutes(60)
});
С помощью наших cookie-policy эти cookie автоматически отправятся как httpOnly и secure. Не нужно переопределять их политику в cookie options.
В CookieOptions можно задать MaxAge, чтобы указать время жизни. Это полезно указывать вместе с JWT Lifetime при выпуске токена, чтобы cookie исчезала по истечении времени. Остальные свойства CookieOptions настраиваются в зависимости от требований проекта.
Для обеспечения большей безопасности советую добавить в Middleware следующие заголовки:
context.Response.Headers.Add("X-Content-Type-Options", "nosniff");
context.Response.Headers.Add("X-Xss-Protection", "1");
context.Response.Headers.Add("X-Frame-Options", "DENY");
- Заголовок X-Content-Type-Options используется для защиты от уязвимостей типа MIME sniffing. Эта уязвимость может возникнуть, когда сайт позволяет пользователям загружать контент, однако пользователь маскирует определенный тип файла как что-то другое. Это может дать злоумышленникам возможность выполнять cross-site scripting сценарии или компрометировать веб-сайт.
- Все современные браузеры имеют встроенные возможности фильтрации XSS, которые пытаются поймать уязвимости XSS до того, как страница будет полностью отображена нам. По умолчанию они включены в браузере, но пользователь может оказаться хитрее и отключить их. Используя заголовок X-XSS-Protection, мы можем фактически сказать браузеру игнорировать то, что сделал пользователь, и применять встроенный фильтр.
- X-Frame-Options сообщает браузеру, что если ваш сайт помещен внутри HTML-фрейма, то ничего не отображать. Это очень важно при попытке защитить себя от попыток clickjacking-взлома.
Я описал далеко не все заголовки. Есть еще куча способов по достижению большей безопасности веб-приложения. Советую ориентироваться на чеклист по безопасности из ресурса securityheaders.com.
Настройка SPA клиента
При расположении клиента и сервера на разных origin требуется дополнительная настройка и на клиенте. Необходимо оборачивать каждый запрос использованием XMLHttpRequest.withCredentials.
Я обернул свои методы следующим образом:
import axios from "axios";
const api = axios.create({ baseURL: process.env.REACT_APP_API_URL });
api.interceptors.request.use(request => requestInterceptor(request))
const requestInterceptor = (request) => {
request.withCredentials = true;
return request;
}
export default api;
Мы можем обернуть свой request config любым способом, главное, чтобы там был withCredentials = true.
Свойство XMLHttpRequest.withCredentials определяет, должны ли создаваться кросс-доменные запросы с использованием таких идентификационных данных, как cookie, авторизационные заголовки или TLS сертификаты.
Этот флаг также используется для определения, будут ли проигнорированы куки, переданные в ответе. XMLHttpRequest с другого домена не может установить cookie на свой собственный домен в случае, если перед созданием этого запроса флаг withCredentials не установлен в true.
Другими словами, если не указать этот атрибут, то наш cookie не сохранится браузером, т.е. мы не сможем обратно отправить cookie на сервер, а сервер не найдет желаемую cookie с JWT и не подпишет Bearer Token в нашем HTTP-pipeline.
Для чего все это нужно?
Выше я описал устойчивый к XSS способ обмена токенами. Пройдемся и посмотрим на результат реализованной функциональности.
Если зайти в Developer Tools, мы наблюдаем заветные флаги httpOnly и secure:
Проведем crush-test, попробуем вытащить куки из клиента:
Мы наблюдаем ' ', т.е. куки не доступны из пространства document, что делает невозможным их чтение с помощью скриптов.
Мы можем попробовать достать эти cookie с помощью дополнительных инструментов или расширений, но все перепробованные мной инструменты вызывали именно нативную реализацию из пространства document.
Демо-проект
Инструкция по запуску находится в README.MD
Автор: neonbones_sp