Немного теории
Аутентификация — процедура проверки подлинности, например проверка подлинности пользователя путем сравнения введенного им пароля с паролем, сохраненным в базе данных. Авторизация — предоставление определенному лицу или группе лиц прав на выполнение определенных действий.
В мире современных мультитенантных систем авторизация играет ключевую роль, обеспечивая безопасный доступ к ресурсам для множества пользователей из различных организаций. Часто организации предпочитают использовать централизованные провайдеры авторизации, такие как Active Directory или Keycloak, для управления пользователями и ролями. Это не только обеспечивает централизованное управление правами доступа, но и позволяет реализовывать механизм единого входа (SSO) для различных цифровых продуктов.
Для реализации единой авторизации в таких мультитенантных системах широко используется протокол OpenID Connect. Этот протокол предоставляет последовательность шагов и правил для выполнения аутентификации и передачи информации о пользователе между приложениями и Identity Provider. Важным аспектом этого протокола является понятие клеймов (claims) — единиц информации о пользователе, которые могут содержать различные атрибуты, такие как его роль, уровень доступа и другие кастомные данные.
SSO и провайдеры аутентификации
Множество организаций используют у себя различные системы для управления пользователями, например Active Directory, который можно применять с протоколом аутентификации LDAP, или Keycloak. Преимущество использования этих программ заключается в централизованном управлении правами и доступами пользователей, а также эти решения позволяют реализовывать SSO (single sign on) в используемых внутри организации цифровых продуктах.
В реализации единой авторизации эти программы выступают в роли Identity Provider — системы управления доступами. Она предоставляет сторонним приложениям некоторую информацию о пользователе — его роль, уровень доступа и т. д.
Одна единица информации о пользователе называется клеймом — claim. Там может содержаться разная информация — от роли и идентификатора пользователя до кастомных полей вроде его даты рождения или города проживания.
Существуют различные протоколы, которые описывают процесс и правила аутентификации в приложении. Самыми популярными являются SAML и OpenId Connect (OIDC). Со вторым мы и будем работать.
Разбираемся с OpenID Connect
Этот протокол определяет последовательность шагов и правил для выполнения аутентификации: какие данные куда передаются, какая часть приложения куда выполняет редирект и как шифруются и передаются claims в приложение-клиент.
В OpenId Connect существует три подхода к реализации аутентификации:
- Implicit Flow — используется, когда вся логика авторизации происходит на фронтенде с помощью JavaScript. Основная проблема тут в том, что пользователь может посмотреть все передаваемые данные в dev tools браузера, поэтому нам необходимо скрывать/шифровать эту информацию.
- Authentication Flow — стандартный процесс аутентификации, который подразумевает вызовы методов Identity Provider на стороне сервера.
- Hybrid Flow — объединяет предыдущие два подхода: разные токены могут передаваться как через фронтенд, так и через серверную часть.
Мы реализуем стандартный Authentication Flow: аутентификация будет происходить на сервере и сохранять информацию о пользователе в cookie.
Keycloak
Это open source identity провайдер — приложение, с помощью которого можно управлять пользователями, ролями, настраивать аутентификацию и авторизацию и др.
Преимущества:
- невысокий порог входа;
- обилие документации;
- большое комьюнити, в том числе и русскоязычное.
На этом сайте в разделе Getting Started можно посмотреть гайды по установке и настройке приложения. Я воспользуюсь Docker, так как это самый простой и удобный вариант. Вот пример файла docker-compose, который я подготовил для этой статьи:
version: '3.3'
services:
keycloak:
image: quay.io/keycloak/keycloak:20.0.2
container_name: keycloak
command:
- start --auto-build --db dev-file --hostname-strict-https false --hostname-strict false --proxy edge --http-enabled true
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
ports:
- 8282:8080
После того как контейнер запустился, зайдем на localhost:8282 (у вас порт может быть другой) и выполним настройку. Нам необходимо добавить нового пользователя и создать клиента для использования аутентификации в стороннем приложении.
Клиента создаем в разделе Clients → Create Client. Сначала вводим название и идентификатор клиента.
На следующем этапе нужно включить галочку Client Authentication.
После нажатия Save в разделе Credentials увидим Client Secret, который можно скопировать и использовать в приложении.
Теперь добавим пользователя в Users → Create User:
Во вкладке Credentials укажем пароль для пользователя:
Создаем проект на ASP.NET Core
Мы будем использовать ASP.NET MVC. Для начала настроим конфигурацию OIDC для одного провайдера — это делается в коде. Можно создать проект в интерфейсе IDE или выполнить команду dotnet new mvc.
Структура созданного проекта будет следующей:
Для добавления аутентификации по протоколу OpenId Connect нам потребуется NuGet-пакет Microsoft.AspNetCore.Authentication.OpenIdConnect. Установить его нужно на основе вашей версии .NET. У меня .NET7, поэтому я выбрал версию 7.0.16.
Теперь добавим конфигурацию OpenId Connect в наше приложение. В файл program.cs после строки builder.Services.AddControllersWithViews(); добавим следующий код:
builder.Services
.AddAuthentication(options =>
{
options.DefaultScheme =
CookieAuthenticationDefaults
.AuthenticationScheme; // сохранять данные аутентификации будем в cookie стандартным механизмом ASP.NET Core
options.DefaultChallengeScheme = "testScheme"; // название схемы авторизации
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
.AddOpenIdConnect("testScheme", options =>
{
options.Authority = "http://localhost:8282/realms/master";
options.CallbackPath = "/signin-oidc-test"; // путь для колбэка openid connect в формате signin-oidc-<что-то>
options.ResponseType = OpenIdConnectResponseType.Code;
options.ResponseMode = OpenIdConnectResponseMode.FormPost;
options.ClientId = "test-client-id"; // clientId, созданный на этапе настройки Keycloak
options.ClientSecret = "<секрет>"; // clientSecret, созданный на этапе настройки Keycloak
options.RequireHttpsMetadata = false; // локально развернутый Keycloak работает по HTTP
options.SaveTokens = true;
options.NonceCookie.SameSite = SameSiteMode.Unspecified;
options.CorrelationCookie.SameSite = SameSiteMode.Unspecified;
});
Немного отвлечемся и посмотрим, как происходит аутентификация через внешний Identity Provider:
- Сначала пользователь выбирает Войти через Keycloak и происходит редирект на адрес Keycloak, где отображается форма с введением логина и пароля.
- Пользователь вводит свои данные и происходит редирект (с cookie) на адрес в формате /signin-oidc-*.
- В процессе обработки редиректа /sign-in-* дефолтная логика ASP.NET получает информацию о пользователе, создает объект Identity с клеймами и сохраняет их.
- Далее происходит редирект на некоторый ReturnUrl, указанный при вызове логики аутентификации. Это уже кастомный эндпойнт, в котором мы можем досталь пользователя и клеймы из HttpContext и выполнить необходимую логику.
Реализация аутентификации
Добавим следующие файлы:
- AccountController в папку Controllers/;
- Index.cshtml в папку Views/Account/.
Файл Index.cshtml будет небольшой — просто кнопка «Войти через Keycloak»:
<h2>Вход на сайт</h2>
<form asp-action="Login" asp-controller="Account" asp-anti-forgery="true">
<div class="form-group">
<input type="submit" value="Войти с помощью keycloak" class="btn btn-outline-dark"/>
</div>
</form>
Теперь реализуем AccountController, добавим метод Index и Login:
public class AccountController : Controller
{
public IActionResult Index()
{
return View();
}
[HttpPost]
public IActionResult Login()
{
if (HttpContext.User.Identity?.IsAuthenticated is not true)
{
return Challenge(new OpenIdConnectChallengeProperties
{
RedirectUri = $"/account/callback",
}, "testScheme");
}
return RedirectToAction("Index", "Home");
}
[HttpGet("account/callback")]
public async Task<IActionResult> OpenIdCallback()
{
await HttpContext.AuthenticateAsync("testScheme");
return RedirectToAction("Index", "Home");
}
}
Чтобы понять, что аутентификация прошла успешно, дополним главную страницу — файл Home/Index.cshtml:
@{
ViewData["Title"] = "Home Page";
}
<div class="text-center">
<h1 class="display-4">Welcome</h1>
@if (User.Identity?.IsAuthenticated is true)
{
<h1 class="display-6">Вы авторизованы!</h1>
<a asp-controller="Account" asp-action="Index">Выйти</a>
<ul class="list-unstyled">
@foreach (var claim in User.Claims)
{
<li>@claim.Type: @claim.Value</li>
}
</ul>
}
@if(User.Identity?.IsAuthenticated is not true)
{
<h1 class="display-5">Вы не авторизованы!</h1>
<a asp-controller="Account" asp-action="Login">Войти</a>
}
</div>
Теперь попробуем запустить приложение. Перед этим немного донастроим наш клиент в Keycloak — добавим информацию о доступных хостах для редиректа на основе порта нашего ASP.NET-приложения:
Вот какая страница открывается после запуска приложения:
Перейдем на адрес /Account по кнопке «Войти»:
Попробуем войти через Keycloak (сначала выполните SignOut в самом Keycloak). Можно использовать логин и пароль нашего созданного пользователя, а можно — админа.
После авторизации на главной странице видим список клеймов, полученных от Keycloak.
Мультитенантность
Мультитенантность — это особенность архитектуры ПО, которая позволяет одному приложению обслуживать несколько независимых арендаторов. В контексте SaaS (Software as a Service) это означает, что одна и та же система может изолированно и безопасно управлять данными и процессами различных клиентов. Каждый клиент работает с приложением, как будто оно было создано специально для него, хотя на самом деле они все используют общую инфраструктуру и кодовую базу.
Клиенты, которые используют такие приложения, могут требовать использования своей внутренней системы аутентификации и авторизации, построенной на базе ADFS или Keycloak. Для этого в SaaS-приложении необходимо разработать функционал динамического добавления различных провайдеров.
Добавляем хранение провайдеров в БД
Чтобы добавить поддержку множества провайдеров, а также добавлять и редактировать их без перезапуска приложения, нам нужно где-то хранить конфигурации. Для примера используем Entity Framework Core и SQLite, но в целом подойдет любая база данных.
Устанавливаем пакеты через nuget:
Далее нам необходимо описать класс с полями, которые будут использоваться в таблице-хранилище конфигураций. Добавим класс OpenIdConnectScheme и заполним его:
public class OpenIdConnectScheme
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
public string Authority { get; set; } // URL нашего identity-провайдера
public string ClientId { get; set; } // ClientId, созданный в idp
public string ClientSecret { get; set; } // ClientSecret, созданный в idp
}
Теперь нам нужно создать контекст, который будет «общаться» с самой БД:
public class OidcDbContext : DbContext
{
public OidcDbContext(DbContextOptions<OidcDbContext> options)
: base(options)
{
}
public DbSet<OpenIdConnectScheme> OpenIdConnectSchemes { get; set; }
}
В файле Program.cs зарегистрируем наш контекст:
builder.Services.AddDbContext<OidcDbContext>(optionsBuilder => optionsBuilder.UseSqlite("db"));
Теперь добавим OpenIdConnectSchemeController — контроллер, который будет обрабатывать пользовательский ввод данных. В этом контроллере реализованы стандартные CRUD-операции. Итоговый код OpenIdConnectSchemeController.cs можно посмотреть в репозитории.
Теперь поработаем над интерфейсом. Добавим несколько cshtml-файлов для интерфейса CRUD-операций.
Структура проекта в папке Views:
Реализация интерфейса доступна в файлах по ссылкам ниже:
Теперь отредактируем файл Account/Index.cshtml — добавим столько кнопок «Войти», сколько у нас провайдеров. Также для корректного вызова метода Challenge нам нужно пробросить в параметры метода Login информацию о добавленных схемах.
Home/Index.cshtml
@model List<OpenIdConnectScheme>
<h2>Вход на сайт</h2>
@foreach (var scheme in Model)
{
<form asp-action="Login" asp-route-scheme="oidc-@scheme.Id" asp-controller="Account" asp-anti-forgery="true">
<div class="form-group">
<input type="submit" value="Войти с помощью keycloak - @scheme.ClientId" class="btn btn-outline-dark"/>
</div>
</form>
}
AccountController.cs
public class AccountController : Controller
{
private readonly OidcDbContext _dbContext;
public AccountController(OidcDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<IActionResult> Index()
{
return View(await _dbContext.OpenIdConnectSchemes.ToListAsync());
}
[HttpPost]
public IActionResult Login(string scheme)
{
if (HttpContext.User.Identity?.IsAuthenticated is not true)
{
return Challenge(new OpenIdConnectChallengeProperties
{
RedirectUri = $"/account/callback?scheme={scheme}",
}, scheme);
}
return RedirectToAction("Index", "Home");
}
[HttpGet("account/callback")]
public async Task<IActionResult> OpenIdCallback(string scheme)
{
await HttpContext.AuthenticateAsync(scheme);
return RedirectToAction("Index", "Home");
}
}
Динамическая регистрация провайдеров
Динамическая регистрация провайдеров аутентификации происходит с помощью следующих классов:
- IAuthenticationSchemeProvider, который хранит список схем аутентификации;
- IOptionsMonitorCache<OpenIdConnectOptions> — кеш для параметров OpenIdConnect;
- OpenIdConnectPostConfigureOptions — класс для сохранения параметров OpenIdConnect.
В OpenIdConnectSchemeController их надо пробросить как зависимости в конструктор.
Далее добавим метод LoadScheme для того, чтобы при сохранении или редактировании информации об OIDC-провайдере мы добавляли информацию в сам ASP.NET Core, и реализуем его следующим образом:
private async Task LoadScheme(OpenIdConnectScheme scheme)
{
// формируем уникальный ключ для схемы аутентификации
var key = "oidc-" + scheme.Id;
var options = new OpenIdConnectOptions();
options.CallbackPath = $"/signin-{key}";
options.Authority = scheme.Authority;
options.ClientId = scheme.ClientId;
options.ClientSecret = scheme.ClientSecret;
options.UsePkce = true;
options.RequireHttpsMetadata = false;
options.SaveTokens = true;
options.ResponseType = OpenIdConnectResponseType.Code;
options.ResponseMode = OpenIdConnectResponseMode.FormPost;
options.NonceCookie.SameSite = SameSiteMode.Unspecified;
options.CorrelationCookie.SameSite = SameSiteMode.Unspecified;
var existingScheme = await _authenticationSchemeProvider.GetSchemeAsync(key);
// если такой схемы еще нет, значит, просто добавляем ее
if (existingScheme is null)
{
AddScheme(key, options);
}
else
{
// если схема уже есть, то заменяем ее в _authenticationSchemeProvider и обновляем кеши
_authenticationSchemeProvider.RemoveScheme(key);
_optionsMonitorCache.TryRemove(key);
AddScheme(key, options);
}
}
private void AddScheme(string key, OpenIdConnectOptions options)
{
_authenticationSchemeProvider.AddScheme(new AuthenticationScheme(key, key, typeof(OpenIdConnectHandler)));
_openIdConnectPostConfigureOptions.PostConfigure(key, options);
_optionsMonitorCache.TryAdd(key, options);
}
Далее в тот же контроллер OpenIdConnectSchemeController, в методы Create, Edit и Delete добавим код для управления схемами аутентификации — вызов метода LoadScheme.
Зависимости в Program.cs
Уберем добавленную ранее схему OpenIdConnect и зарегистрируем в DI класс OpenIdConnectPostConfigureOptions — вот что должно получиться:
// Add services to the container.
builder.Services.AddControllersWithViews();
builder.Services.AddDbContext<OidcDbContext>(optionsBuilder => optionsBuilder.UseSqlite("Data Source=Application.db"));
builder.Services
.AddAuthentication(options =>
{
options.DefaultScheme =
CookieAuthenticationDefaults
.AuthenticationScheme; // сохранять данные аутентификации будем в cookie стандартным механизмом ASP.NET Core
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme);
builder.Services.AddSingleton<OpenIdConnectPostConfigureOptions>();
Запускаем приложение
Перейдем по пути <ваш адрес>:<порт>/openidconnectscheme:
Добавим новую конфигурацию — например, аналогичную той, на которой тестировали приложение:
По пути /Account появится кнопка для перехода:
Теперь добавим еще одного клиента в Keycloak:
Укажем данные:
Сохраним и снова перейдем на страницу входа /Account.
Попробуем войти с помощью любой схемы — получаем положительный результат.
Заключение
В этой статье мы научились динамически добавлять провайдеры аутентификации в приложение на ASP.NET Core. Весь исходный код вы найдете в моем репозитории DmitryBahtenkov/OidcArticle (github.com).
Также я веду телеграм-канал о разработке, где разбираю интересные задачи из практики, а еще публикую обзоры на книги и приложения.