Как защитить ASP.NET Core с помощью веб-токенов OAuth и JSON
OAuth 2.0 — это стандарт авторизации, который вы, вероятно, уже как-то использовали. Когда службе требуется информация из вашей учетной записи GitHub или Google, вы разрешаете это с помощью OAuth, прежде чем они смогут получить данные. Для защиты вашего API с помощью OAuth уже есть несколько вариантов, таких как Identity Server 4, OpenIddict и ASOS. Но все эти пакеты либо требуют от вас использования реляционной базы данных, такой как MSSQL, установки дополнительных веб-приложений и/или предлагают слишком много опций, которые могут запутать и усложнить изучение. В этой статье я покажу, как можно использовать веб-токены JSON (JWT) для реализации потока кода авторизации OAuth 2.0 непосредственно с помощью ASP.NET Core, сохраняя при этом выбор постоянного хранилища открытым.
Поток кода авторизации
В OAuth 2.0 существует множество различных потоков, которые можно использовать в разных сценариях. Однако целью всех потоков является получение токена доступа. Токен доступа — это то, что можно использовать для запроса API, чтобы получить запрашиваемую информацию. Наиболее часто используемым потоком в современных приложениях как для мобильных устройств, так и для Интернета является поток кода авторизации. Многие другие потоки обрабатывают всю связь с сервером авторизации внутри и не предоставляют пользователю доступ к другой службе. Поток кода авторизации предоставляет пользователю доступ к серверу авторизации через браузер. Для этого стандарта важно использовать HTTPS, поскольку между различными субъектами происходит много общения с аргументами, которые не должны быть известны потенциальным перехватчикам. На следующем рисунке показан поток.
Запрос кода авторизации и ответ
Сначала клиент перенаправляется на сайт сервера авторизации. Здесь пользователь должен сначала аутентифицировать себя, что можно сделать с помощью проверки подлинности файлов cookie при использовании конуса ASP.NET, как описано в этом сообщении: Проверка подлинности файлов cookie с социальными провайдерами в ASP.NET Core . В перенаправлении клиент должен прикрепить некоторые аргументы: response_type
которые должны быть, "code"
поскольку мы используем поток кода авторизации. client_id
с указанием клиента, так как только зарегистрированным клиентам должно быть разрешено использовать сервер авторизации. redirect_uri
укажите URI, на который сервер должен перенаправить обратно после авторизации доступа.state
— это случайная строка, которая используется для идентификации потока. Клиент также должен аутентифицировать себя, что может быть выполнено либо с использованием базовой аутентификации HTTP, либо с использованием дополнительного аргумента client_secret
, который содержит секретный пароль, известный клиенту.
Действие, которое получает эти параметры, может выглядеть следующим образом.
[Authorize]
public async Task<IActionResult> authorize(string response_type, string client_id, string redirect_uri, string redirect_uri, string state)
{
// Check if code is correct and if client credentials are correct.
if (response_type != "code")
{
return Redirect(redirect_uri + "?error=unsupported_response_type");
}
if (!clientValidator.Valid(client_id, client_secret))
{
return Redirect(redirect_uri + "?error=access_denied");
}
// Generate authorization code and save it together with userId and recirect_uri
string code = Guid.NewGuid().ToString();
string user = User.Claims.First(c => c.Type.equals == ClaimTypes.Name);
codeAndUserStorage.Save(code, user);
codeAndURIStorage.Save(code, redirect_uri);
// Return view
ViewBag.redirect_uri = redirect_uri;
ViewBag.code = authCode;
ViewBag.state = state;
return View();
}
У действия есть [Authorize]
атрибут, потому что нам нужно войти в систему, чтобы знать, какого пользователя мы хотим авторизовать. При использовании проверки подлинности с помощью файлов cookie ASP.NET Core автоматически перенаправляет пользователя на страницу входа, чтобы убедиться, что они вошли в систему перед авторизацией. Мы проверяем, что код response_type
есть, "code"
и отвечаем ошибкой, если это не так. Мы также проверяем, что client_id
и client_secret
допустимы. Эти учетные данные могут храниться в таблице в вашей базе данных, в виде файла в хранилище BLOB-объектов Azure или, если вы знаете, что ваши клиенты редко изменяются (или для тестов), в виде жестко заданного словаря.
Затем code
генерируется авторизация и извлекается пользовательская Name
. Если вы используете аутентификацию cookie, то Name
они будут храниться где-то в Claims
вашей реализации. User Name
и the redirect_uri
сохраняются в месте, откуда их можно получить с помощью более code
позднего. Это снова можно сохранить в таблице в вашей базе данных или использовать какое-либо другое постоянное хранилище, например хранилище BLOB-объектов Azure. Должен code
быть доступен только в течение нескольких минут и обычно потребляется в течение нескольких секунд. Наконец, переменные, необходимые для ответа, добавляются в представление, ViewBag
и возвращается представление. Простое представление может быть реализовано следующим образом:
<h2>Do you want to give the application that you were redirected from access to your account?</h2>
<a href="@ViewBag.redirect_uri?code=@ViewBag.code&state=@ViewBag.state">Authorize</a>
Пользователь перенаправляется обратно к клиенту по этой ссылке, и клиент получает авторизацию code
и проверяет, что state
это то же самое, что и отправленное.
Запрос токена доступа и ответ
Теперь, когда у клиента есть авторизация code
, он может запросить токен доступа, отправив файл code
. Этот запрос также должен включать аргументы: grant_type
для которых необходимо установить значение "authorization_code"
. redirect_uri
который должен быть идентичен redirect_uri
в первом запросе. client_id
как в предыдущем запросе. А также, client_secret
если вы выберете это в качестве аутентификации клиента. Действие, которое получает это, может выглядеть так:
[HttpPost]
public async Task<IActionResult> AccessToken([FromForm]string code, [FromForm]string grant_type, [FromForm]string redirect_uri, [FromForm]string client_id, [FromForm]string client_secret)
{
// Check if code is correct and if client credentials are correct.
if (grant_type != "authorization_code")
{
return Redirect(redirect_uri + "?error=unsupported_response_type");
}
if (!clientValidator.Valid(client_id, client_secret))
{
return Redirect(redirect_uri + "?error=access_denied");
}
// Extract the URI and user
string previous_uri = codeAndURIStorage.Load(code);
string user = codeAndUserStorage.Load(code);
codeAndURIStorage.Delete(code);
codeAndUserStorage.Delete(code);
// Check if the new uir is the same as the previous and that the userId was found
if (redirect_uri != previous_uri)
{
return BadRequest("'redirect_uri' was inconsistent.");
}
if (user == null)
{
return BadRequest("Couldn't find user associated with the given code.");
}
// Creates the signed JWT
var symmetricSecurityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["TokenOptions:Key"]))
var tokenHandler = new JwtSecurityTokenHandler();
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(new Claim[]
{
new Claim(ClaimTypes.Name, user)
}),
Expires = DateTime.UtcNow.AddYears(2),
Issuer = "MyWebsite.com",
Audience = "MyWebsite.com",
SigningCredentials = new SigningCredentials(symmetricSecurityKey, SecurityAlgorithms.HmacSha256Signature)
};
var token = tokenHandler.CreateToken(tokenDescriptor);
var access_token = tokenHandler.WriteToken(token);
// Returns the 'access_token' and the type in lower case
return Ok(new { access_token, token_type="bearer" });
}
Это действие, в отличие от предыдущего действия, принимает свои аргументы через сообщение. Это означает, что вы должны извлечь каждый параметр, используя атрибут [FromForm]
. В качестве альтернативы можно было бы сделать модель для поста, но для этой цели мы выбрали более простой, но немного более беспорядочный подход. Во-первых, в теле grant_type
проверяются учетные данные клиента и , аналогично началу нашего предыдущего действия. Затем previous_uri
и user
извлекаются из того места, где они хранились непосредственно перед этим. После этого они удаляются, чтобы каждый код авторизации можно было использовать только один раз для создания действительного токена доступа. Затем мы проверяем, что previous_uri
это то же самое, redirect_uri
что гарантирует, что это code
пришло от того же клиента. Мы также проверяем, user
не null
означает ли это, чтоcode
на самом деле был настоящим кодом авторизации и что он ранее не использовался.
Теперь мы подошли к части, где мы делаем токен доступа. Наш токен доступа — это веб-токен JSON (JWT). JWT — это стандарт для представления заявок на ресурсы в пределах определенного объема, времени и аудитории. Мы устанавливаем Subject
новое значение ClaimsIdentity
, которое содержит пользователя в поле Name
, которое будет использоваться для определения пользователя, для которого этот токен используется после его использования. Мы установили срок его действия через 2 года, что является долгим сроком, но мы вернемся к этому позже. Мы устанавливаем Issuer
и Audience
для одного и того же веб-сайта, поскольку в этом примере мы будем создавать и использовать токен в одном и том же приложении. Мы устанавливаем SigningCredentials
использование симметричного ключа безопасности, поскольку это единственное приложение, которому нужно будет создавать и проверять токены. Мы извлекаем этот симметричный ключ безопасности изConfiguration
который всегда автоматически внедряется во все контроллеры. Configuration
может ссылаться на ваш appsettings.json
файл или, что еще лучше, на ваши пользовательские секреты, как описано в нашей статье ASP.NET Core (не тот секрет) Объяснение пользовательских секретов .
Наконец, токен создается и сериализуется как строка. Когда токен создается, он использует все содержимое JWT вместе с SigningCredentials
для создания подписи. Это делает так, что никто не может изменить токен. Его могут прочитать все, но только мы можем сделать их и проверить правильность их подписи. Если ваш способ идентификации ваших пользователей не должен быть виден всем с токеном, вы можете вместо этого использовать JWE, который шифрует содержимое токена. Наконец, мы возвращаем access_token
клиенту и указываем, что это токен-носитель, установив token_type
для параметра значение "bearer"
. Это означает, что каждый, у кого есть этот токен, может использовать его без использования какого-либо другого криптографического ключа или идентификации.
Использование access_token в вашем API
Теперь нам нужно найти какой-нибудь способ проверить access_token
правильность и извлечь его содержимое, чтобы мы могли его использовать. К счастью, .NET упрощает нам эту задачу. После Startup.cs
того, как вы добавили аутентификацию, вы добавляете следующее:
var symmetricSecurityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["TokenOptions:Key"]));
services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
// here the cookie authentication option and other authentication providers will are added.
.AddJwtBearer(options =>
{
options.SaveToken = true;
options.TokenValidationParameters = new TokenValidationParameters()
{
ValidIssuer = "MyWebsite.com",
ValidAudience = "MyWebsite.com",
IssuerSigningKey = symmetricSecurityKey
};
});
services.AddAuthorization(options =>
options.AddPolicy("ValidAccessToken", policy =>
{
policy.AuthenticationSchemes.Add(JwtBearerDefaults.AuthenticationScheme);
policy.RequireAuthenticatedUser();
}));
Сначала мы создаем симметричный ключ безопасности аналогично тому, как мы это делали ранее. Затем мы добавляем JWT Bearer в качестве варианта аутентификации в том же месте, где добавляется аутентификация cookie. Сначала мы устанавливаем значение SaveToken
, true
чтобы утверждения были доступны через User.Claims
. Параметры проверки токена аналогичны полям, которые мы использовали при создании токена, за исключением того, что мы используем только те, которые относятся к подписи и действительности токена.
Затем мы добавляем политику авторизации для приложения, которая указывает, что ему нужен токен носителя JWT для действий/контроллеров, использующих эту политику. В автозагрузку нам также нужно добавить, что приложение использует аутентификацию и авторизацию, если оно еще этого не сделало:
app.UseAuthentication();
app.UseAuthorization();
Теперь вы просто добавляете политику в атрибут действия, которое вы хотите проверить с помощью JWT, как показано здесь:
[Authorize(Policy = "ValidAccessToken")]
public async Task<IActionResult> Me()
{
var user = User.Identity.Name;
var firstName = database.load(user).FirstName
return Ok(new { user=user, firstName=firstName });
}
Это действие довольно простое, но оно показывает основную идею. Вы можете использовать действие как любое другое действие, но у вас также есть доступ к содержимому JWT. Обычно сервер авторизации также имеет конечную точку, которая идентифицирует пользователя, который сделал токен, подобный этому Me
действию. Токен доступа можно передать вашему приложению сейчас, добавив поле в заголовок запроса к вашему приложению Authorization
, которое имеет содержимое "Bearer <access_token>"
(слово Bearer, за которым следует пробел, а затем ваш токен доступа).
Возможные улучшения
Когда мы создали токен доступа, мы сделали уведомление о времени истечения срока действия токена. Мы установили его на 2 года, что довольно долго, но в некоторых случаях вам нужен долгоживущий токен. В документации по стандарту OAuth 2.0 рекомендуется, чтобы токен доступа был недолговечным. Способ, которым это может быть достигнуто без необходимости повторной авторизации каждый час, заключается в использовании токена обновления. Токен обновления может быть просто длинной случайной строкой. Он работает таким образом, что вы можете использовать токен обновления вместе с токеном доступа с истекшим сроком действия, чтобы получить новый токен доступа. Затем токен обновления будет сгенерирован одновременно с первым токеном доступа и сохранен в каком-либо постоянном хранилище с подключением к пользователю. Когда токен обновления передан, пользователя можно проверить по недействительному токену доступа, а токен обновления можно сравнить с токеном в постоянном хранилище для этого конкретного пользователя и удалить, чтобы его нельзя было использовать дважды. Важно, чтобы токен обновления хранился безопасно и никогда не транспортировался без TLS или не отображался в URI или в браузере, поскольку это открывает возможности для возможных атак. Это делает токен доступа более безопасным в использовании, поскольку безопасность приложения не будет скомпрометирована, если злоумышленник получит токен доступа, но это также делает так, что вам нужно сохранить еще одну вещь в постоянном хранилище. Важно, чтобы токен обновления хранился безопасно и никогда не транспортировался без TLS или не отображался в URI или в браузере, поскольку это открывает возможности для возможных атак. Это делает токен доступа более безопасным в использовании, поскольку безопасность приложения не будет скомпрометирована, если злоумышленник получит токен доступа, но это также делает так, что вам нужно сохранить еще одну вещь в постоянном хранилище. Важно, чтобы токен обновления хранился безопасно и никогда не транспортировался без TLS или не отображался в URI или в браузере, поскольку это открывает возможности для возможных атак. Это делает токен доступа более безопасным в использовании, поскольку безопасность приложения не будет скомпрометирована, если злоумышленник получит токен доступа, но это также делает так, что вам нужно сохранить еще одну вещь в постоянном хранилище.
Еще одним возможным улучшением является определение области действия при запросе кода авторизации. Область действия — это область вашего приложения, которая может сузить круг того, к чему вы предоставляете доступ, например "read-email"
или "make-posts"
. Области аналогичны утверждениям в удостоверениях и могут быть легко добавлены к токенам JWT. Рекомендуется определить область, если у вас есть много клиентов, которые используют ваше приложение по-разному, чтобы все токены доступа не имели одинаковый доступ.
Существует множество способов атаковать OAuth, поэтому различные стандарты безопасности постоянно обновляются. Это довольно простой подход к созданию сервера OAuth 2.0, использующего поток кода авторизации. Таким образом, вероятно, будут некоторые ошибки, и можно было бы сделать больше соображений по ограничению генерации различных токенов и кодов и общего мониторинга, чтобы избежать злоупотреблений.
Вывод
Я расширил приложение, чтобы оно могло работать как сервер авторизации в соответствии со стандартным потоком кода авторизации OAuth 2.0. Затем я показал, как можно использовать токен доступа, полученный от сервера авторизации. В конце я обсудил, почему может быть хорошей идеей использовать токен обновления и другие улучшения, рекомендуемые или необязательные для потока. Если у вас есть какие-либо вопросы или отзывы, не стесняйтесь обращаться к нам.
Только полноправные пользователи могут оставлять комментарии. Аутентифицируйтесь пожалуйста, используя сервисы.