Вступление
Идентификация по 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
UPD: Защита против CSRF
Настройка ASP.NET Core сервера
Middleware Services
public class XsrfProtectionMiddleware
{
private readonly IAntiforgery _antiforgery;
private readonly RequestDelegate _next;
public XsrfProtectionMiddleware(RequestDelegate next, IAntiforgery antiforgery)
{
_next = next;
_antiforgery = antiforgery;
}
public async Task InvokeAsync(HttpContext context)
{
context.Response.Cookies.Append(
".AspNetCore.Xsrf",
_antiforgery.GetAndStoreTokens(context).RequestToken,
new CookieOptions {HttpOnly = false, Secure = true, MaxAge = TimeSpan.FromMinutes(60)});
await _next(context);
}
}
public static class MiddlewareExtensions
{
public static IApplicationBuilder UseXsrfProtection(this IApplicationBuilder builder, IAntiforgery antiforgery)
=> builder.UseMiddleware<XsrfProtectionMiddleware>(antiforgery);
}
ConfigureServices
services.AddAntiforgery(options => { options.HeaderName = "x-xsrf-token"; });
services.AddMvc();
Configure
app.UseAuthentication();
app.UseXsrfProtection(antiforgery);
Настройка SPA
import axios from "axios";
import cookie from 'react-cookies';
const api = axios.create({ baseURL: process.env.REACT_APP_API_URL });
api.interceptors.request.use(request => requestInterceptor(request))
const requestInterceptor = (request) => {
request.headers['x-xsrf-token'] = cookie.load('.AspNetCore.Xsrf')
return request;
}
export default api;
Использование
Чтобы защитить наши API-методы, необходимо добавить атрибут
[AutoValidateAntiforgeryToken]
— для контроллера или [ValidateAntiForgeryToken]
— для метода. Комментарии (10)
vanbukin
22.09.2019 23:53Только обязательно настроить Data Protection API, чтобы выставленные куки шифровались, а так же валидировать подпись JWT токена. В противном случае, я как злоумышленник могу пойти и подредачить значение куки, прописав туда любые клеймы, которые захочу и плакала вся авторизация.
neonbones_sp Автор
23.09.2019 10:35Подпись JWT валидируется с помощью настройки в AddJwtBearer -> TokenValidationParameters.
Не совсем понял что значит пойти и подредачить, я именно от этого и старался защититься используя серверные (httpOnly) куки.
Можно подредактировать, если зайти в Dev Tools на браузере с авторизованным пользователем, но тут уже вопрос о нерасторопности человека который отдал свои credentials. В таком случае, это целесообразно чтобы человек не переподписал на себя другую роль.
andreyverbin
23.09.2019 00:42Отличный подход, я решал похожую задачу через собственный формат ISecureDataFormat, кода получилось сильно больше.
До полноценного решения ещё далеко. Если память мне не изменяет, то в Claims, а затем в JWT попадает SecurityStamp. AspNet.Identity использует это поле для генерации 2fa токенов для totp аутентификации. Зная токен, теоретически можно генерировать такие же токены. Потому крайне желательно шифровать security stamp в JWT.
MisFis123
23.09.2019 10:13Здравствуйте, я правильно понял что такой jwt нельзя просить для получения его body? Тогда не лучше ли использовать session id он и по объему меньше и там нет оверхеда на внутренние поля
neonbones_sp Автор
23.09.2019 10:54Здравствуйте, используя сессию, мы получаем состояние, что немного нарушает спецификации REST.
Запрос к серверу по спецификации REST не должен хранить контекст между запросами. Каждый запрос от любого клиента должен содержать всю информацию, необходимую для обслуживания запроса.
Куки отличаются от сессии. Файлы куки хранятся клиенте и общение происходит посредством HTTP-заголовка, поэтому я считаю что httpOnly куки хорошо подойдут для решения данной задачи.
Кто то может со мной не согласиться, потому что это довольно холиварный вопрос. Кто то говорит что и куки ломают спецификацию REST.
Michael_SL
23.09.2019 17:31Спасибо за статью! Скажите, а вы пробовали подключать swagger (или OpenApi) к API с такой системой аутентификации?
ultrinfaern
Из-за того что вы превратили jwt в обычный ид сессии храня его в куках, вся схема прекрасно подходит для CSRF атаки.
Обычно jwt добавляют в авторизационный заголовок:
Authorization: Bearer [jwt]
edk55
Для того, чтобы отправлять токен в заголовке, его необходимо хранить в месте, доступном из JS. (память/localStorage/sessionStorage, etc) Это порождает уязвимость к cross-site scripting (XSS) атакам.
neonbones_sp Автор
Хорошее замечание. Добавил реализацию защиты против CSRF.