Еще весной 2025 затеял я переход на внешнюю подсистему аутентификации и авторизации. Выбрал для этого Keycloak - поддерживает по умолчанию нужные возможности, достаточно популярен, бесплатен, работает и под Windows, ресурсы жрет умеренно.
У меня там немало всякого, но в первую очередь взялся за бэкенд. Итак, есть сервис ASP.NET Core 8, Web API, контроллер. Что нужно сделать?
Вообще, тема безопасности в ASP.NET Core вроде и расписана хорошо, ан нет - полно мест, где без практики и раскопок документации и форумов, нихрена ничего не понятно. Начнем с того, что в моем случае, используется JWT - токен доступа, токен обновления. Я должен провести на некоем клиентском приложении (десктоп, WinForms или WPF) аутентификацию у Keycloak, получить эти 2 токена, и, используя токен доступа, обратиться к бэкенду.
Бэкенд же, в этом сценарии должен сделать, условно, 4 вещи - десериализовать JWT в JSON, проверить токен по ряду правил, и грамотно отобразить данные из токена доступа в данные по пользователю и его ролям. Дальше, в соответствии с ролями и прописанными правилами доступа, выполнить затребованный метод, или отклонить запрос со статусом 401 Unauthorized (не пройдена аутентификация), или 403 Forbidden (нет прав доступа).
С указанием прав доступа, все просто: добавил к классу контроллера атрибут [Authorize(Roles = "...")], к методам, которые требуют простого доступа, без каких-либо прав и даже без аутентификации, - [AllowAnonymous]. AllowAnonymous полностью отменяет Authorize, так что его указание обозначает публичный доступ без ограничений.
Замечательно, теперь настройка в Main. Для начала определим два важных параметра:
string authority =
$"{builder.Configuration["Keycloak:Server-url"]}/realms{builder.Configuration["Keycloak:Realm"]}";
string validIssuer = authority;
Authority обозначает того, кто выпускает токены (в моем случае, это зона (Realm) Keycloak). Issuer - содержимое поля iss в JWT, используется для проверки, правильный ли автор у токена. Иногда Authority и Issuer совпадают, но это необязательно. Более подробно можно посмотреть здесь: https://stackoverflow.com/questions/69229596/differences-between-audience-issuer-and-client-terms-in-jwt-oauth-and-oidc.
builder.Configuration["Keycloak:Server-url"] и builder.Configuration["Keycloak:Realm"] указывают на настройки, вынесенные в appsettings.json:
"Keycloak": {
"Server-url": "https://....",
"Realm": "..."
}
Теперь сложный момент. Сначала приведу код целиком:
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.Authority = authority;
options.Audience = "account";
// By default, the claims mapping will map claim names in the old format to accommodate older SAML applications.
// 'http://schemas.microsoft.com/ws/2008/06/identity/claims/role' instead of 'roles'
// This flag ensures that the ClaimsIdentity claims collection will be built from the claims in the token
// https://github.com/Azure-Samples/active-directory-aspnetcore-webapp-openidconnect-v2/issues/368
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
RoleClaimType = "roles",
ValidateAudience = true,
ValidAudience = "account",
// https://stackoverflow.com/questions/60306175/bearer-error-invalid-token-error-description-the-issuer-is-invalid
ValidateIssuer = true,
ValidIssuer = validIssuer,
};
options.RequireHttpsMetadata = true;
options.SaveToken = true;
// для решения проблем с нестандартными самоподписанными сертификатами на стороне keycloak; лучше не использовать, если есть возможность
//options.BackchannelHttpHandler = new HttpClientHandler
//{
// ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
//};
});
builder.Services.AddAuthorization();
И еще где-то перед MapControllers, нужно добавить:
app.UseAuthentication();
app.UseAuthorization();
ServerCertificateCustomValidationCallback - это я не тестировал, так что просто записываю на всякий случай. В чем проблема с сертификатами: нужно указывать CN, соответствующий серверу Keycloak. И еще нужно импортировать сертификат Keycloak (server.crt.pem) на том сервере, где работает бэкенд, в "Доверенные корневые центры сертификации / СертификатыДоверенные корневые центры сертификации / Сертификаты" (Windows). Сертификат для Keycloak у меня получилось создать только в Linux:
openssl req -newkey rsa:2048 -nodes -keyout keycloak_server_win.key.pem -x509 -days 3650 -out keycloak_server_win.crt.pem -subj "/CN=<IP сервера Keycloak>"
А до выполнения всех этих требований, я в ответе сервиса ASP.NET Core (в заголовках) получал ошибку:
"[Bearer error=""invalid_token"", error_description=""The signature key was not found""]"
После того, как я отключил на отладчике опцию Just My Code, и запустил сервис под отладчиком, я смог найти начальную ошибку:
The remote certificate is invalid according to the validation procedure: RemoteCertificateNameMismatch, RemoteCertificateChainErrors
Для установки под Windows - удалось все настроить, а вот для Docker + Ubuntu - тут я еще буду разбираться.
Теперь подробней с настройками:
- Audience - фиксированное значение для Keycloak (по крайней мере, для его настроек по умолчанию)
- MapInboundClaims = false - без этого клеймы нормально не отображаются на роли. Вроде как старая проблема. Точнее, есть вроде еще вариант - не включать эту опцию, а в RoleClaimType указать "http://schemas.microsoft.com/ws/2008/06/identity/claims/role". Есть у меня подозрение, что проблемы с клеймами возникли из-за того, что я там повозился в Keycloak с настройками, но я так и не разобрался, и разбираться не буду, потому что времени совсем нет на это.
- И да, RoleClaimType - это не только для проверки, но и непосредственно для определения того, где искать эти самые роли
- Про то, куда чего отображается, можно посмотреть в JwtSecurityTokenHandler.DefaultInboundClaimTypeMap (на старте сервиса в Main, например)
Про настройку Keycloak и про аутентификацию на десктоп-клиенте постараюсь написать в следующих статьях.