Skip to content

kontur-web-courses/auth

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

32 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Auth

0. Подготовка

Требуется научиться запускать приложение PhotosApp и убедиться, что все хорошо. Остальные приложения из solution пока запускать не надо.

Приложение использует https, поэтому нужно установить доверие к сертификатам ASP.NET Core. Если ты проходил другие блоки, то возможно доверие к сертификатам уже установлено. Если нет, то склонируй репозиторий https://github.com/kontur-web-courses/dev-certs и следуй инструкциям из него.

Запусти приложение PhotosApp под отладкой. Должен запусться браузер и открыть стартовую страницу приложения. Если браузер не откроется, то самостоятельно открой адрес https://localhost:5001. Убедись, что при запуске в папке PhotosApp автоматически создался файл PhotosApp.db с базой данных Sqlite.

Открой файл PhotosApp.db с помощью сервиса https://sqliteonline.com/. Убедись, что в нем есть таблица Photos, выведи записи из нее с помощью запроса SELECT * FROM Photos;.

На всякий случай небольшой ликбез.

В этом задании надо будет часто подключать using к файлу после вставки кода. Вручную прописывать using не надо. Надо установить курсор на слово с неизвестным типом, нажать мышкой на появившуюся слева лампочку и выбрать команду добавляющую using.

Конечно, проще это сделать с помощью сочетания клавиш:

  • Visual Studio Code: Ctrl+.
  • Visual Studio: Ctrl+. или Alt+Enter
  • ReSharper: Alt+Enter
  • Rider: Alt+Enter

В этом задании придется работать с большим количеством файлов и может быть удобно искать файлы по имени, а также типы по имени. Обычно IDE это прекрасно умеют делать: главное знать сочетание клавиш для этого функционала.

Используй сочетание клавиш для своей IDE и найди в решении файл RemotePhotosRepository.cs:

  • Visual Studio Code: Ctrl+P
  • Visual Studio: Ctrl+T
  • ReSharper: Ctrl+T
  • Rider: Ctrl+T или Shift, Shift

1. Identity

Identity — это встроенная реализация аутентификации в ASP.NET Core. Она состоит из классов, реализующих логику, в том числе UserManager для управления пользователями приложения и SignInManager для управлением сеансами взаимодействия пользователей с приложением, а также включает набор готовых страниц для входа, регистрации и других действий.

Если требуется реализовать аутентификацию пользователей, то использование Identity — это более предпочтительный вариант, чем написание собственной аутентификации с нуля, ведь в последнем случае легко написать «работающую» аутентификацию с кучей дыр в безопасности.

Identity же можно гибко конфигурировать, в том числе заменить встроенный UI на собственные страницы. Что далее и надо сделать.

1.1. Scaffolding

Чтобы использовать стандартный UI из Identity, его достаточно подключить как библиотеку. Но тогда UI не получится изменить. Чтобы можно было UI менять, нужно сгенерировать копии всех стандартных страниц прямо внутрь PhotosApp. Identity обнаружит эти сгенерированные страницы и будет использовать их, вместо встроенных. Конечно, встроенный UI и сгенерированный UI сначала будут совпадать, но затем сгенерированный UI можно будет доработать. Пора приступать к генерации кода!

Scaffolding (англ. строительные леса) — генерация кода по заданной разработчиком спецификации.

Прежде всего потребуется установить новый инструмент для .NET CLI — генератор кода:

dotnet tool install -g dotnet-aspnet-codegenerator

Кроме того, в проект надо добавить NuGet-пакеты для кодогенерации. Выполни в папке с проектом PhotosApp:

dotnet add package Microsoft.VisualStudio.Web.CodeGeneration.Design
dotnet add package Microsoft.EntityFrameworkCore.Design

Наконец, можно выполнить команду генерации кода Identity (также в папке с проектом PhotosApp):

dotnet aspnet-codegenerator identity -dc UsersDbContext -u PhotosAppUser -sqlite

Дополнительные параметры команды указывают:

  • имя DbContext, который будет использоваться для хранения информации о пользователях,
  • C#-класс для пользователя, хранимого в базе данных,
  • что, в качестве базы данных надо использовать Sqlite, а не SQL Server.

В проекте PhotosApp в папке Areas/Identity был сгенерирован требующийся код.

Area в ASP.NET Core MVC — это в каком-то смысле подприложение с собственной адресацией. В случае Identity все адреса страниц будут иметь префикс /Indentity, после которого будет обычный путь до страницы.

Посмотри структуру папки Areas/Identity.

Обрати внимание на файл Areas/Identity/IdentityHostingStartup.cs. Код из него будет автоматически запускаться после Startup.cs и завершать конфигурирование. Также обрати внимание, что страницы сгенерированы по технологии Razor Pages. В отличие от MVC, где «разметка» (View) и «обработка» (Controller) находится в разных местах, здесь все находится в двух соседних файлах. Например, для страницы Identity/Pages/Account/Login.cshtml, разметка находится в Login.cshtml, а обработка в Login.cshtml.cs.

Чтобы Razor Pages заработали, надо их добавить в DI-контейнер, а также подключить в качестве обработчиков запросов.

Код для ConfigureServices:

var mvc = services.AddControllersWithViews();
services.AddRazorPages();
if (env.IsDevelopment())
    mvc.AddRazorRuntimeCompilation();

Замечение. Компиляция представлений на лету без перестроения всего проекта, подключаемая командой AddRazorRuntimeCompilation, будет работать и для представлений из контроллеров, и для Razor Pages.

Код для Configure:

app.UseEndpoints(endpoints =>
{
    endpoints.MapControllerRoute("default", "{controller=Photos}/{action=Index}/{id?}");
    endpoints.MapRazorPages();
});

Также в Views/Shared был сгенерирован _LoginPartial.cshtml, содержищий разметку для отображения ссылок для регистрации/входа/выхода пользователя в меню приложения.

Ссылки регистрации/входа/выхода надо добавить в меню приложения. Для этого открой Views/Shared/_Layout.cshtml и добавь вставку _LoginPartial.cshtml с помощью tag-хелпера сразу после меню навигации:

<ul class="navbar-nav mr-auto">
    ...
</ul>
<partial name="_LoginPartial"/>

Можешь запустить PhotosApp и убедиться, что ссылки появились и при переходе по ним открываются страницы Identity.

Только ничего не работает, потому что пользователей нет, да и таблица базы данных для них еще не создана.

1.2. Миграции

Для манипулирования базой данных нужно установить инструмент Entity Framework Core для .NET CLI:

dotnet tool install --global dotnet-ef

В коде при выполнении scaffolding появился новый контекст. Чтобы обновить БД для хранения данных из него, надо создать миграцию. Для этого выполни в папке с проектом PhotosApp:

dotnet ef migrations add Users --context UsersDbContext

Миграция — это план обновления. Его можно применять к БД, которыми будет пользоваться приложение.

Чтобы миграция успешно создалась:

  1. Приложение должно компилироваться без ошибок,
  2. При старте приложения не должно быть ошибок, т.е. код конфигурирования (в Startup.cs и IdentityHostingStartup.cs) должен работать корректно
  3. Приложение не должно быть запущенным. Все это нужно, чтобы команда миграции смогла построить и запустить проект, а затем получить через рефлексию всю необходимую информацию о контекстах базы данных.

Если при запуске миграции встретилась такая замысловатая ошибка про «design time», проверь предыдущие три пункта. Пример такой ошибки: Unable to create an object of type 'UsersDbContext'. For the different patterns supported at design time, see https://go.microsoft.com/fwlink/?linkid=851728

После создания миграции ее надо запустить на имеющейся базе данных Sqlite. Сделай это с помощью следующей команды:

dotnet ef database update --context UsersDbContext

Теперь база данных обновлена и в ней можно хранить информацию о пользователях. Посмотри с помощью https://sqliteonline.com/ какие таблицы были созданы. Заметь, что их достаточно много.

Для целей разработки можно вместо dotnet ef database update использовать такой вызов метода:

dbContext.Database.Migrate()

Здесь dbContext — экземпляр класса-наследника DbContext.

Посмотри как метод Migrate используется в файле Data/PhotosAppDataExtensions.cs для PhotosDbContext, и вызови Migrate аналогичным образом для UsersDbContext. В результате, если файл PhotosApp.db будет удален, то при запуске приложения он автоматически восстановится со всеми таблицами.

Теперь надо добавить тестовых пользователей. Это умеет делать заготовленный метод SeedWithSampleUsersAsync из Data/PhotosAppDataExtensions.cs. Вызови его в PrepareData, чтобы при старте приложения создавались тестовые пользователи. Подсказка 1: Так как метод асинхронный, то его результат надо дождаться, вызвав метод Wait. Подсказка 2: UserManager<PhotosAppUser> можно достать из ServiceProvider аналогично PhotosDbContext.

1.3. Аутентификация

Чтобы под пользователем можно было зайти, подключи middleware аутентификации UseAuthentication и middleware авторизации UseAuthorization в Startup.cs. Их вызов обязательно должен быть после подключения middleware для определения обработчика запроса (UseRouting), но перед подключением middleware для выполнения запроса (UseEndpoints). Это позволит слою UseAuthorization проверить пользователя на соответствие требованиям обработчика, который стал известен после UseRouting, и отменить выполнение обработки в UseEndpoints, если у пользователя не хватает прав. Слой UseAuthentication также пользуется информацией из UseRouting. Должно получиться так:

app.UseRouting();

app.UseAuthentication();
app.UseAuthorization();

app.UseEndpoints(endpoints => ...);

Теперь попробуй зайти под пользователем cristina. Email и пароль можно найти в Data/PhotosAppDataExtensions.cs.

Но даже если зайти под нужным пользователем, его фотки не будут показываться, пока не поправить PhotosController. Измени метод GetOwnerId так, чтобы он возвращал идентификатор залогиненного пользователя, а не идентификатор vicky:

private string GetOwnerId()
{
    return User.FindFirstValue(ClaimTypes.NameIdentifier);
}

Снова зайди под cristina, а затем под vicky. Фотографии должны быть разными.

Остался небольшой нюанс: Logout работает некорректно. После него не происходит перехода на главную страницу приложения, а в верхнем меню остается имя пользователя. Это происходит потому, что в _LoginPartial.cshtml указан некорректный asp-route-returnUrl. Должен быть @Url.Action("Index", "Photos", new { area = "" }).

1.4. Авторизация

Теперь надо сделать, чтобы анонимный пользователь автоматически пересылался на страницу входа при выполнении любых действий с фотографиями. Доступной должна остаться только главная страница.

Для этого достаточно пометить атрибутом [Authorize] все методы или контроллеры, которые требуется защитить. Если пометить атрибутом [Authorize] контроллер, но надо разрешить некоторый метод, то метод помечается атрибутом [AllowAnonymous].

Защити все действия над фотографиями из PhotosController, кроме Index.

1.5. Требования к паролям

Настройки по умолчанию для паролей хороши:

  • есть требования на длину и используемые символы,
  • пароли не хранятся о открытом виде, а хэшируются с солью.

Но такие настройки не всегда подходят.

В большинстве случаев достаточно конфигурирования. Начни с этого.

Настройки по умолчанию для паролей можно посмотреть тут: https://docs.microsoft.com/ru-ru/aspnet/core/security/authentication/identity-configuration#password

Заодно в том же документе можно посмотреть настройки по умолчанию для входа: https://docs.microsoft.com/ru-ru/aspnet/core/security/authentication/identity-configuration#sign-in

Чтобы облегчить себе жизнь во время прохождения блока:

  1. Скопируй явную конфигурацию паролей и входа из документации в IdentityHostingStartup.cs
  2. Выстави настройки для паролей RequireDigit, RequireNonAlphanumeric, и RequireUppercase в false. Оставь RequireLowercase в true!
  3. Также выстави в настройках входа RequireConfirmedAccount в false.
  4. Найди где еще в IdentityHostingStartup.cs выставляется RequireConfirmedAccount в true и удали эту настройку.

В реальных проектах так делать не надо, это только для разработки и обучения :)

При желании можешь поменять пароли для vicky, cristina и dev в файле Data/PhotosAppDataExtensions.cs, чтобы было проще.

Зарегистрируй нового пользователя с простым паролем из 6 символов: у тебя должно получиться. Затем выйди из него и зайди снова. Вход должен получиться, несмотря на то, что ты не подтверждал email.

Все же может потребоваться добавить новые нестандартные правила проверки паролей. Например, проверить, что новый пароль не совпадает с логином пользователя. Проверка уже реализована в Services/UsernameAsPasswordValidator.cs. Изучи ее код. А затем добавь строчку .AddPasswordValidator<UsernameAsPasswordValidator<PhotosAppUser>>() в цепочку вызовов после .AddDefaultIdentity<PhotosAppUser>() и убедись, что нельзя зарегистрировать пользователя, если пароль совпадает с email.

1.6. Алгоритм хэширования паролей

Может понадобится изменить алгоритм хэширования паролей. Например, если есть база пользователей, которым надо дать доступ к приложению, но при их регистрации использовался свой собственный алгоритм хэширования.

Если использовалась предыдущая версия Identity, то алгоритм хэширования можно просто донастроить, указав верную версию и количество итераций при хэшировании:

services.Configure<PasswordHasherOptions>(options =>
{
    options.CompatibilityMode = PasswordHasherCompatibilityMode.IdentityV3;
    options.IterationCount = 12000;
});

Полностью заменить алгоритм хэширования на свой можно так:

services.AddScoped<IPasswordHasher<PhotosAppUser>, SimplePasswordHasher<PhotosAppUser>>();

Вот только SimplePasswordHasher из папки Services не до конца реализован. Имея реализацию метода HashPassword, дореализуй метод VerifyHashedPassword.

Чтобы убедиться, что реализация корректна, используй тесты на SimplePasswordHasher, которые находятся в том же файле. Тесты можно запускать по-разному. Один из способов запустить сразу все — выполнить команду dotnet test в папке PhotosApp.

Затем подключи SimplePasswordHasher, с помощью Debug убедись, что теперь используется он, а вход под пользователями с новым алгоритмом хэширования работает.

1.7. Локализация

Все ошибки, которые ты видел, были на английском языке. Это не очень удобно для русскоговорящих пользователей.

В большинстве случаев тексты ошибок на английском прописаны в файлах Identity/Pages. Например, в файле Register.cshtml.cs в классе InputModel с помощью атрибутов. У любого атрибута для валидации есть свойство ErrorMessage, в котором можно прописать текст сообщения об ошибке на русском языке. Таким образом эти тексты ошибок легко локализуются. Задай текст сообщения для атрибута Required в свойстве Email класса InputModel.

Но кроме атрибутов для локализации нужно поменять реализацию IdentityErrorDescriber. Уже есть готовая реализация, позаимствованная со StackOverflow: Services/RussianIdentityErrorDescriber.cs. В файле IdentityHostingStartup.cs в конфигурировании Identity (найди services.AddDefaultIdentity<PhotosAppUser>()) добавь строчку .AddErrorDescriber<RussianIdentityErrorDescriber>().

Теперь попробуй зарегистрировать нового пользователя:

  • Сначала заполни email, а затем сделай пустым. Ты должен увидеть сообщение об ошибке из атрибута Required. Благодаря jquery.validate сообщение появляется до отправки формы.
  • Теперь введи корректный email, но в качестве пароля используй 6 цифр, например, 123456. Отправь форму. Если все правильно, то в ответ получишь сообщение из RussianIdentityErrorDescriber: «Пароль должен содержать хотя бы один символ в нижнем регистре»

Как локализовать весь остальной пользовательский интерфейс ясно: надо локализовывать файлы из папки Identity/Pages. Сейчас, по понятным причинам, этого делать не нужно.

1.R. Резюме

Ты научился подключать к веб-приложению на ASP.NET Core простейшую аутентификацию и авторизацию с помощью встроенной Identity, а также в некоторой степени ее конфигурировать. При этом такую аутентификацию сложно считать полноценной даже для простейших приложений.

Дополнительно ты познакомился с понятием scaffolding, миграциями в EntityFramework Core, а также реализацией хэширования паролей с солью.

2. Сессии

После успешной аутентификации информация о пользователе по умолчанию хранится в cookie. Браузер постоянно передает эту cookie на сервер и за счет этого все действия пользователя можно авторизовать.

По умолчанию все работает некоторым образом. Настройки по умолчанию можно посмотреть тут: https://docs.microsoft.com/ru-ru/aspnet/core/security/authentication/identity-configuration#cookie-settings

Если предыдущая ссылка не открылась некорректно, то тут: https://docs.microsoft.com/ru-ru/aspnet/core/security/authentication/identity-configuration?#no-loccookie-settings

Давай донастроим. Для этого:

  1. Скопируй явную конфигурацию из документации в IdentityHostingStartup.cs.
  2. Выстави options.Cookie.Name значение "PhotosApp.Auth", чтобы сессия хранилась в cookie с известным именем.
  3. Обрати внимание на настройку options.Cookie.HttpOnly = true. Это значит, что cookie не будет доступна клиентским скриптам, что обычно правильно и защищает пользователя от атак со скриптов.
  4. Настройка options.SlidingExpiration = true означает, что сессия не «протухнет», пока пользователь активно использует приложение. Это тоже хорошее поведение.

Теперь залогинься под любым пользователем и найди в меню приложения ссылку на страницу Decode и перейди по ней. На этой странице аутентификационная кука расшифровывается, а затем информация из нее выводится.

При обработке запросы middleware UseAuthentication и UseAuthorization получают из запроса информацию о пользователе и сохраняют ее для использования. В частности, собранная информация оказывается в поле User контроллера.

Информация о User выводится ниже на странице Decode. Видно, что сейчас в cookie и в User хранится одна и та же информация. Других источников, кроме cookie, пока просто нет.

Посмотри /Views/Shared/_IdentityDecodePartial.cshtml, чтобы понять, как можно достать данные о пользователе из cookie и из User. Запоминать тонкости реализации не нужно.

Стоит убедиться, что данные в cookie защищаются с помощью IDataProtector, реализацию которого можно задать. В частности, чтобы cookie зашифровывались по некоторому алгоритму с известным серверу ключом при создании и расшифровывались при использовании.

Также стоит разобраться с используемыми понятиями:

  • Обрати внимание, что «пользователь» (User) может включать в себя несколько «личностей» (Identities). Это нужно, потому что информация о пользователе может приходить из разных источников (cookie, headers и т.д.) и может быть разной. В одном случае — это Вики, в другом — Скарлетт Йоханссон. И все личности — это один пользователь.
  • Также обрати внимание, что при расшифровке cookie из нее достается AuthenticationTicket, т.е. «удостоверение». «Удостоверение» хранит разную информацию и в частности свойство Principal. «Приципал» — это лицо, от чьего имени может действовать предъявитель удостоверения. В данном случае браузер, который отправил нашему сервису cookie.
  • Наконец, еще одно понятие — claims. Это некоторые «утверждения» про «личность», представляющие из себя пары ключ-значение. Например, «возраст = 72», «роль = режиссер», «гражданство = США». На основании этих «утверждений» пользователю могут быть доступны те или иные действия в приложении.

Если надо хранить в сессии много данных о пользователе, то аутентификационная кука станет достаточно большой. Неэкономично заставлять браузер передавать все эти данные с каждым запросом в виде cookie. В этом случае можно хранить данные о сессии на сервере.

Для хранения сессии на сервере хорошо подойдет распределенной InMemory хранилище. InMemory — для скорости, распределенное — для отказоустойчивости. Например, подойдет Redis. Но для учебных целей воспользуемся все тем же Sqlite.

Готовые хранилища уже реализованы в Services/EntityTicketStore.cs и Services/MemoryCacheTicketStore.cs Посмотри как они устроены.

MemoryCacheTicketStore проще, потому что хранит всю информацию о сессиях в оперативной памяти. Это очень быстро, но перезагрузка веб-сервера заставит пользователей входить заново. Не надо так.

Поэтому подключать стоит EntityTicketStore, сделай это в IdentityHostingStartup.cs:

services.AddTransient<EntityTicketStore>();
services.ConfigureApplicationCookie(options =>
{
    var serviceProvider = services.BuildServiceProvider();
    options.SessionStore = serviceProvider.GetRequiredService<EntityTicketStore>();
    /* добавленный ранее код конфигурации */
});

Так как это хранилище использует Entity Framework Core, надо его сконфигурировать, а затем выполнить миграцию и обновление базы данных:

  1. Сконфигурируй TicketsDbContext в IdentityHostingStartup.cs аналогично UsersDbContext
  2. Добавь значение для TicketsDbContextConnection в appsettings.json, причем можешь снова использовать PhotosApp.db в качестве файла БД
  3. dotnet ef migrations add Tickets --context TicketsDbContext в папке PhotosApp
  4. dotnet ef database update --context TicketsDbContext в папке PhotosApp, но лучше добавить dbContext.Database.Migrate() в Data/PhotosAppDataExtensions.cs
  5. Вызови метод SeedWithSampleTicketsAsync в Data/PhotosAppDataExtensions.cs, передав туда TicketsDbContext, чтобы зачищать все сессии перед стартом приложения. Пользователи каждый раз пересоздаются — значит нет смысла хранить сессии.

После подключения снова залогинься и перейди на страницу Decode. Обрати внимание, что теперь в аутентификационной куке хранится только идентификатор сессии. Вся остальная информация о пользователе хранится и незаметно достается из Sqlite, поэтому в поле User данные остались, что видно внизу страницы Decode.

2.R. Резюме

Ты познакомился с важными понятиями в реализации аутентификации ASP.NET Core:

  • ticket — удостоверение
  • principal — принципал (тот на кого выдано удостоверение, является личностью)
  • identity — личность
  • claim — утверждение (про личность)
  • user — пользователь (носитель одной или нескольких личностей)

Ты немного больше узнал о сеансе (или сессии) пользователя:

  • Данные сеанса можно хранить не только в cookie, но и централизовано на стороне сервера
  • Сеанс может иметь sliding expiration, а значит его время действия не закончится, пока пользователь активен

3. Роли и политики

3.1. Роли

Не весь функционал должен быть доступен каждому пользователю. Требуется ограничить права различных групп пользователей.

Один из способов — ввести систему ролей. Добавь новую роль Dev, присвой ее уже имеющемуся пользователю dev@gmail.com и сделай так, чтобы только пользователи с ролью Dev имели доступ к DevController.

Подсказки:

  • В конфигурировании Identity в IdentityHostingStartup.cs надо добавить .AddRoles<IdentityRole>() сразу после .AddDefaultIdentity<PhotosAppUser>()
  • Нужно создать роль в БД. Код создания роли уже есть в методе SeedWithSampleRolesAsync. Сделай так, чтобы метод SeedWithSampleRolesAsync из Data/PhotosAppDataExtensions.cs выполнялся при создании БД, т.е. в методе PrepareData, причем до SeedWithSampleUsersAsync. Тебе понадобится RoleManager<IdentityRole>: достань его из ServiceProvider.
  • Добавить пользователю новую роль можно командой await userManager.AddToRoleAsync(user, "RoleName")
  • Защитить метод или контроллер можно с помощью атрибута с параметром: [Authorize(Roles = "RoleName")]

Когда закончишь, убедись, что только пользователь dev@gmail.com может пользоваться страницей Decode, а у других пользователей возникает сообщение об ошибке.

Будет хорошо, если пользователи, которым недоступен Decode вообще не будут видеть ссылку на страницу. Сделай так! Проверить во view, что у текущего пользователя есть роль можно так: User.IsInRole("RoleName")

Обрати внимание, что просто убрать ссылку, не помечая контроллер атрибутом Authorize, было бы небезопасно. В таком случае злоумышленник, зная о существовании страницы Decode, смог бы на нее попасть. Поэтом обеспечивать безопасность прежде всего нужно на стороне API, а затем уже скрывать лишнее на стороне UI.

3.2. Политики

Более гибко настраивать права пользователей позволяют политики на основании различных claims (claim - утверждение) пользователя.

Сейчас любому пользователю при заходе на страницу отдельной фотографии доступно изменение подписи к фотографии. Сделай так, чтобы возможность редактировать подписи к фотографиям была доступна только beta-тестерам.

Для начала в IdentityHostingStartup.cs нужно зарегистрировать некоторую политику:

services.AddAuthorization(options =>
{
    options.AddPolicy(
        "Beta",
        policyBuilder =>
        {
            policyBuilder.RequireAuthenticatedUser();
            policyBuilder.RequireClaim("testing", "beta");
        });
});

Эта политика требует, чтобы пользователь был аутентифицирован и у него был claim testing со значением beta. Сейчас таких пользователей нет.

Сделай так, чтобы при создании пользователя vicky в PrepareData добавлялся такой claim. Подсказка: await userManager.AddClaimAsync(user, new Claim("claimType", "claimValue")). Claim, добавленные таким образом хранятся в отдельной таблице. Можешь в этом убедиться с помощью https://sqliteonline.com/.

Теперь защити действие EditPhoto (и GET, и POST запросы) в PhotosController с помощью атрибута [Authorize(Policy = "Beta")].

Когда закончишь, убедись, что только пользователь vicky@gmail.com может редактировать подписи к фотографиям, а у других пользователей возникает сообщение об ошибке.

Будет хорошо, если пользовали, которым недоступно редактирование подписей, вообще не увидят ссылки на это действие. Проверить во view выполнение политики для пользователя можно так:

(await AuthorizationService.AuthorizeAsync(User, "PolicyName")).Succeeded

Только надо добавить в начале view подключение зависимостей:

@using Microsoft.AspNetCore.Authorization
@inject IAuthorizationService AuthorizationService

Скрой действие «Изменить подпись» на странице отдельной фотографии.

Когда закончишь с этим добавь еще одну политику: пусть только платым пользователям будет доступна загрузка фотографий. Назови политику CanAddPhoto, в качестве типа claim используй subscription, в качестве значения paid. Аналогично предыдущей политике, защити методы PhotosController для загрузки фотографий. и скрой ссылку «Добавить фото» в меню приложения. Также скрой ссылку на метод AddPhoto в Index.cshtml, которая показывается, когда у пользователя нет фотографий.

А вот claim в пользователя надо выставить иначе. Путь он не хранится отдельно в таблице, а вычисляется по свойствам из PhotosAppUser.

Для этого:

  1. Добавь в класс PhotosAppUser булево свойство Paid.
  2. Создай миграцию, т.к. надо добавить новую колонку в таблицу пользователей: dotnet ef migrations add Paid --context UsersDbContext в папке PhotosApp
  3. Разбери generic-параметр TUser в методе SeedWithSampleUsersAsync, заменив его использования на тип PhotosAppUser.
  4. Сделай так, чтобы пользователю cristina при создании в свойство Paid выставлялось значение true.
  5. Самое важное! Допиши класс CustomClaimsPrincipalFactory в файле Services/Authorization/CustomClaimsPrincipalFactory.cs. Сначала замени во всем файле использование IdentityUser на PhotosAppUser, а затем сделай так, чтобы пользователю с Paid == true выставлялся claim subscription со значением paid.
  6. Зарегистрируй фабрику в IdentityHostingStartup.cs в конфигурации Identity, добавив для этого вызов .AddClaimsPrincipalFactory<CustomClaimsPrincipalFactory>() в цепочку вызовов после .AddRoles<IdentityRole>().

Убедись, что пользователю cristina доступно добавление фото, а для vicky не доступно.

Как видишь, определив собственную UserClaimsPrincipalFactory, можно выставить пользователю нужные claims по произвольным правилам «на лету», т.е. без хранения самих claims в базе данных.

3.3. Обработчик для требования

В приложении до сих пор любой аутентифицировавшийся пользователь может открыть любую фотографию, если у него будет прямая ссылка до нее.

Убедись в этом:

  1. Зайди под пользователем vicky
  2. Перейди на страницу с одной фотографией и сохрани URL страницы
  3. Выполни logout и зайди под пользователем cristina
  4. Используй сохраненный URL, чтобы открыть фотографию. Она доступна другому пользователю!

Чтобы создать политику, которая бы запрещала доступ к фото другим пользователям, потребуется AuthorizationHandler.

Для начала создай новую политику MustOwnPhoto, а в ней потребуй два условия:

policyBuilder.RequireAuthenticatedUser();
policyBuilder.AddRequirements(new MustOwnPhotoRequirement());

MustOwnPhotoRequirement — некоторое требование, которое будет проверяться динамически с помощью обработчика. Обработчик для этого требования уже добавлен. Это класс MustOwnPhotoHandler. Он может быть обработчиком требования, потому что наследуется от класса AuthorizationHandler<MustOwnPhotoRequirement>.

Но, чтобы обработчик использовался его надо зарегистрировать в качестве IAuthorizationHandler:

services.AddScoped<IAuthorizationHandler, MustOwnPhotoHandler>();

Защити действия GetPhoto, GetPhotoFile, EditPhoto (оба), DeletePhoto в PhotosController с помощью новой политики. Заметь, что это нормально использовать несколько атрибутов Authorize у метода. В этом случае для выполнения действия должны быть выполнены требования каждого атрибута.

Допиши MustOwnPhotoHandler так, чтобы требование выполнялось, если текущий пользователь является владельцем фотографии.

3.R. Резюме

Ты познакомился с авторизацией на основе ролей и политик, а также научился предъявлять произвольные требования к пользователю за счет обработчиков требований. Оказалось, что политики более гибкий механизм, чем роли, а значит надо использовать их. Теперь авторизация в приложении достаточно гибкая.

4. Аутентификация через Google

ASP.NET Core включает встроенную поддержку для OAuth, за счет чего к нему легко подключить внешних провайдеров аутентификации. А для некоторых, включая Google и Facebook, есть даже готовые методы, позволяющие подключить провайдера, написав пару строчек.

Добавь следующий код в IdentityHostingStartup.cs:

services.AddAuthentication()
    .AddGoogle("Google", options =>
        {
            options.ClientId = context.Configuration["Authentication:Google:ClientId"];
            options.ClientSecret = context.Configuration["Authentication:Google:ClientSecret"];
        });

Это почти все, что нужно, чтобы заработала аутентификация через Google в случае Identity, потому что отображение нужных кнопок для внешних провайдеров аутентификации уже реализовано в UI для Identity.

Осталось только зарегистрировать приложение в Google, получить Client ID и Client Secret, а затем положить их в настройки, чтобы следующие строчки работали корректно:

options.ClientId = configuration["Authentication:Google:ClientId"];
options.ClientSecret = configuration["Authentication:Google:ClientSecret"];

Для этого:

Google любит обновлять интерфейс, но шаги выглядят примерно так: Они выглядят примерно так

  1. Перейди на страницу https://developers.google.com/identity/sign-in/web/sign-in#create_authorization_credentials
  2. Перейди по ссылке «Credentials page»
  3. Нажми на кнопку «Create credentials» и выбери «OAuth client ID» из выпадающего меню
  4. Выбери опцию Web application в качестве типа приложения
  5. Придумай и введи имя нового проекта
  6. Перед тем как нажать «Create», задай https://localhost:5001/signin-google в качестве «Authorized redirect URIs»
  7. Нажми на кнопку «Create», а затем получи и сохрани куда-нибудь Client ID и Client Secret

/signin-google — это путь, по которому Google отправит данные пользователя после успешной аутентификации. Такой адрес используется по умолчанию в ASP.NET Core, соответственно, данные от Google будут успешно получены и обработаны Authentication Middleware.

Client ID и Client Secret используются для авторизации приложения в Google. Их можно сохранить в appsettings.json по ключам Authentication:Google:ClientId и Authentication:Google:ClientSecret и все будет работать. Но файлы, хранящиеся в репозитории, в том числе appsettings.json — это плохое место для хранения паролей и секретов.

Поэтому лучше воспользоваться специальным хранилищем для секретов вот так в папке PhotosApp:

dotnet user-secrets set "Authentication:Google:ClientId" "<client id>"
dotnet user-secrets set "Authentication:Google:ClientSecret" "<client secret>"

Важно выполнять команды в папке PhotosApp, т.к. секреты сохраняются независимо для каждого проекта. Этот проект можно указать явно в команде, либо выполнить команду из папки с проектом.

В этом случае значения будут сохранены тут:

  • %APPDATA%\Microsoft\UserSecrets\<user_secrets_id>\secrets.json в Windows
  • ~/.microsoft/usersecrets/<user_secrets_id>/secrets.json в Linux, Mac

В Visual Studio секретами можно управлять, если кликнуть правой кнопкой мыши по проекту в «Solution Explorer» и выбрать пункт «Manage Secrets».

После сохранения реквизитов в хранилище для секретов не обязательно их удалять из appsettings.json, потому что значения из хранилища более приоритетны и перетрут значения из appsettings.json.

Далее своими проектами в Google можно будет управлять через специальный «пульт»: https://console.developers.google.com/apis/credentials

После верного задания Client ID и Client Secret аутентификация через Google должна появиться на странице логина и корректно работать. Проверь! Правда помни, что у нового пользователя из Google согласно текущим политикам не будет ни фото, ни возможности их добавить.

4.R. Резюме

Многие пользователи предпочитают использовать внешние провайдера аутентификации, вместо того, чтобы создавать логин и пароль для каждого веб-приложения. Теперь ты знаешь как подключить внешнего провайдера аутентификации на примере Google.

5. Письма

Хорошая практика — предлагать пользователю подтвердить адрес своей электронной почты, чтобы случайная опечатка при вводе email или забытый пароль не приводили к потере доступа к аккаунту.

Identity пытается отправлять письма с кодом подтверждения всем новым пользователям с помощью IEmailSender. По умолчанию он реализован так, что ничего не отправляет.

В Services/SimpleEmailSender есть реализация, которая умеет отправлять письма через внешний SMTP-сервер. Подключи ее:

services.AddTransient<IEmailSender, SimpleEmailSender>(serviceProvider =>
    new SimpleEmailSender(
        serviceProvider.GetRequiredService<ILogger<SimpleEmailSender>>(),
        serviceProvider.GetRequiredService<IWebHostEnvironment>(),
        context.Configuration["SimpleEmailSender:Host"],
        context.Configuration.GetValue<int>("SimpleEmailSender:Port"),
        context.Configuration.GetValue<bool>("SimpleEmailSender:EnableSSL"),
        context.Configuration["SimpleEmailSender:UserName"],
        context.Configuration["SimpleEmailSender:Password"]
    ));

Большинство настроек для подключения к SMTP-серверу Google уже прописаны в appsettings.json. Пропиши в файл или в User Secrets адрес своей электронной почты Google в UserName и соответствуйщий пароль в Password.

Также, чтобы «стороннее приложение», которое ты пишешь, смогло отправлять письма придется понизить уровень безопасности аккаунта на странице https://myaccount.google.com/lesssecureapps

Зарегистрируй нового пользователя с существующим email и убедись, что на него пришло письмо для подтверждения адреса электронной почты.

Если нужно, чтобы без подтверждения email нельзя было войти в аккаунт, следует изменить настройку SignIn.RequireConfirmedEmail для Identity. Но в обучающем проекте нам это не нужно.

Хоть подтвреждение почты является важной частью регистрации, в дальнейших заданиях она не понадобится. Поэтому можно вернуть обратно настройки безопасности аккаунта Google: https://myaccount.google.com/lesssecureapps А для настроек UserName и Password в appsettings.json задай пустые строки в качестве значений.

5.R. Резюме

Ты познакомился с тем, как пользоваться сервером отправки почты из ASP.NET Core.

Отправка писем для подтверждения адреса электронной почты — это последний компонент качественной встроенной в приложение аутентификации и авторизации. Сейчас настройку Identity можно считать завершенной.

6. Json Web Token и схемы аутентификации

6.1. Json Web Token

Сейчас тебе предстоит добавить нестандартный способ аутентификации в сервисе. Работать он должен так: пользователь переходит по секретному URL, где ему выставляется cookie с JWT-токеном. Этот короткоживущий токен дает доступ разработчика к сервису на полминуты.

Найди и открой HackController. В методе GenerateToken с суперсекретным адресом вызывается генерация JWT-токена. Затем этот токен добавляется в cookie.

Для начала надо доработать генерацию токена в методе TemporaryTokens.GenerateEncoded.

  1. Сделай так, чтобы токен не действовал до текущего момента. Для этого надо передать текущее время в UTC в notBefore.
  2. Сделай так, чтобы токен действовал всего лишь 30 секунд, задав правильно expires.
  3. Заполни claims:
    • Утверждению ClaimTypes.NameIdentifier (идентификатор пользователя) задай значение Guid.NewGuid().ToString()
    • Утверждению ClaimsIdentity.DefaultNameClaimType (имя пользователя) задай какое-нибудь значение
    • Утверждению ClaimsIdentity.DefaultRoleClaimType (роль пользователя) задай значение "Dev"
  4. Чтобы токен нельзя было подделать можно добавить зашифрованный с помощью симметричного ключа отпечаток. В этом случае получатель токена, если у него есть ключ шифрования, сможет построить свои отпечаток и сравнить с отпечатком, добавленным издателем. Если отпечатки не совпадут, значит токен поддельный. Воспользуйся алгоритмом HMAC SHA-256. SHA-256 — хэш-функция, HMAC — алгоритм, использующий симметричный ключ и некоторую хэш-функцию для получения отпечатка. Все уже реализовано, надо только правильно задать signingCredentials. Используй ключ из свойства TemporaryTokens.SigningKey, а имя алгоритма есть в константе SecurityAlgorithms.HmacSha256.

Теперь, если обратиться по пути /hack/super_secret_qwe123 будет возвращен токен. Он также окажется в cookie.

Получи и расшифруй этот токен с помощью сервиса https://jwt.io/. Передай зашифрованный вариант и убедись, что в «PAYLOAD» там заданные тобой данные.

Добейся того, чтобы появилась надпись «Signature Verified». Алгоритм подписи должен автоматически выставиться в HS256, т.к. он передается в заголовке токена, так что осталось передать правильный ключ симметричного шифрования в блоке «VERIFY SIGNATURE».

Итак, правильный JWT-токен уже можно получить в виде cookie. Но пока приложение никак на это не реагирует. Надо это исправить.

Добавь следующий код в IdentityHostingStartup.cs:

services.AddAuthentication()
    .AddJwtBearer(options =>
    {
        options.RequireHttpsMetadata = false;
        options.TokenValidationParameters = new TokenValidationParameters
        {
        };
    });

После этого аутентификация по JWT-токены в какой-то степени начнет поддерживаться. Но надо донастроить.

В TokenValidationParameters:

  1. Выстави ValidateIssuer и ValidateAudience в false, потому что информация об издателе и получателях токена не добавлялась.
  2. Выстави ValidateLifetime в true, чтобы старые токены не работали. Также задай ClockSkew = TimeSpan.Zero. Дело в том, что токены генерируются и проверяются обычно на разных серверах и время на них может отличаться. Поэтому при проверке токенов допускается погрешность в несколько минут. Это правильно, но для корректной работы токена в нашем сценарии с временем жизни в полминуты нужно от погрешности отказаться.
  3. Выстави ValidateIssuerSigningKey в true, чтобы проверялся отпечаток токена. В IssuerSigningKey передай использованный при создании отпечатка ключ.

Еще один нюанс — откуда будет доставаться токен. Обычно JWT-токены передаются в HTTP-заголовке Authorization и подписью Bearer, которая указывает, что авторизация будет с помощью токена «на предъявителя». Выглядит это примерно так:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.Et9HFtf9R3GEMA0IICOfFMVXY7kkTX1wr4qCyhIf58U

Замечание. Другим распространенным способом авторизации является Basic, в которой в HTTP-заголовке Authorization передается подпись Basic, после чего идет строка логин:пароль (например, aladdin:opensesame), закодированная с помощью base64. Выглядит это примерно так:

Authorization: Basic YWxhZGRpbjpvcGVuc2VzYW1l

Сейчас же токен хранится в cookie. Но можно подсказать ASP.NET Core откуда брать токен вот так:

options.Events = new JwtBearerEvents
{
    OnMessageReceived = c =>
    {
        c.Token = c.Request.Cookies["NameOfCookieWithToken"];
        return Task.CompletedTask;
    }
};

Только не забудь передать правильное имя cookie.

Аутентификация по JWT-токенам теперь должна работать.

Проверь, что токен правильно генерируется и преобразуется в User:

  1. Перейди по секретному адресу /hack/super_secret_qwe123 и получи токен.
  2. Перейди на секретную страницу декодирования пользователя /hack/decode, которая требует авторизацию токеном и убедись, что пользователь заполнен.

6.2. Несколько схем аутентификации

Пора настроить авторизацию. Сейчас с настройками по умолчанию они использует только Identity. А использование JwtBearer приходится задавать явно, как в HackController. Надо сделать так, чтобы авторизация поддерживала оба способа аутентификации: Identity и JwtBearer.

Сначала теория. У каждого способа аутентификации есть идентификатор — схема. Identity использует сразу несколько схем. Название основной схемы хранится в константе IdentityConstants.ApplicationScheme и равно "Identity.Application". Также в Identity используется схема "Identity.External" для внешних провайдеров, например, Google. Для JwtBearer значение схемы по умолчанию хранится в константе JwtBearerDefaults.AuthenticationScheme и равно "Bearer". Если надо добавить поддержку нескольких видов JwtBearer, то можно задать схему явно:

services.AddAuthentication()
    .AddJwtBearer("SomeJWT", options => { /* */ })
    .AddJwtBearer("AnotherJWT", options => { /* */ });

Identity тоже добавляет свой способ аутентификации при вызове services.AddDefaultIdentity<PhotosAppUser>(). Внутри AddDefaultIdentity скрыт следующий вызов:

services.AddIdentityCookies();

А в нем регистрируются сразу несколько способов аутентификации через cookie, в том числе упомянутые "Identity.Application" и "Identity.External".

Каждый способ аутентификации определяет поведение в следующих ситуациях: Challenge, Authenticate, SignIn, SignOut, Forbid. Эти ситуации удобно описать на примере способа аутентификации с cookie:

  • Challenge — «вызвать схему», используется если у пользователя не хватает прав, перекидывает на форму ввода логина и пароля
  • Authenticate — «аутентифицировать», из cookie достается информация о пользователе и формируется ClaimsIdentity.
  • SignIn — «войти», формируется ClaimsIdentity и сохраняется в cookie
  • SignOut — «выйти», cookie удаляется
  • Forbid — «запретить», переход на страницу, информирующей о запрете доступа

Поведение в этих ситуациях зависит от обработчика и переданных ему опций.

Например, вызов .AddJwtBearer("SomeJWT", options => { /* */ }) определяет способ аутентификации с идентификатором "SomeJWT", обработчиком JwtBearereHandler и некоторыми опциями по умолчанию, которые можно дополнить. А вызов .AddIdentityCookies определяет несколько способов аутентификации, один из которых имеет идентификатор "Identity.Application", обработчик CookieAuthenticationHandler и некоторыми опциями.

Раз схем много, то встает вопрос какая из них будет использоваться. Например, если выполнить вызов

HttpContext.SignInAsync(scheme, principal, properties)

Тут очевидно, что использоваться будет схема из переменной scheme. А вот если ее не передать, как часто бывает, то из настройки DefaultSignInScheme, либо из настройки DefaultScheme, если предыдущая не найдена.

Кажется, что настройки DefaultSignInScheme и DefaultScheme нигде не задаются, но это не так. Вызов services.AddAuthentication("Bearer") задает строку "Bearer" в качестве DefaultScheme. А в методе AddDefaultIdentity скрыта такая конфигурация:

services.AddAuthentication(o =>
{
    o.DefaultScheme = IdentityConstants.ApplicationScheme;
    o.DefaultSignInScheme = IdentityConstants.ExternalScheme;
});

Identity в качестве схемы по умолчанию — это нормально, пусть так и будет. А надо сделать так, чтобы [Authorize] стал поддерживать новый способ аутентификации. Для этого надо переопределить политику авторизации по умолчанию следующим образом:

services.AddAuthorization(options =>
{
    options.DefaultPolicy = new AuthorizationPolicyBuilder(
        JwtBearerDefaults.AuthenticationScheme,
        IdentityConstants.ApplicationScheme)
        .RequireAuthenticatedUser()
        .Build();
        /* добавленный ранее код конфигурации */
}

Новая политика использует и Identity, и JwtBearer.

Пришло время для проверки:

  1. Перейди по секретному адресу /hack/super_secret_qwe123 и получи токен.
  2. Перейди на главную страницу и убедись, что доступна ссылка «Decode», как и всем разработчикам.
  3. Перейди по ней и убедись, что сам метод пока еще не доступен.

Настройка политики по умолчанию никак не влияет на атрибуты с дополнительными настройками, в том числе на [Authorize(Roles = "Dev")], поэтому страница по ссылка «Decode» еще не работает.

Быстрый способ починить — дополнительно к роли перечислить в атрибуте допустимые схемы через запятую:

[Authorize(Roles = "Dev", AuthenticationSchemes = "Bearer, Identity.Application")]

Но так делать сейчас не надо.

Качественный способ решить эту проблему — везде для конфигурирования авторизации использовать политики, как ранее использовали [Authorize(Policy = "CanAddPhoto")]. Глядя на политику по умолчанию и другие политики, добавь политику "Dev". Тебе пригодятся методы RequireRole и AddAuthenticationSchemes у policyBuilder. Для корректной работы этой политики надо добавить обе схемы: JwtBearerDefaults.AuthenticationScheme и IdentityConstants.ApplicationScheme. Причем именно в таком порядке! Объяснения почему будет немного позже.

Как добавишь политику — используй ее! Во-первых, в DevController, поправив атрибут Authorize. Во-вторых, при показе ссылки «Decode» в _Layout.cshtml, сделав аналогично показу ссылки «Добавить фото». После этого ссылка «Decode» должна заработать. Причем как для аутентификации по токену, так и для пользователя dev@gmail.com.

Итоговая проверка:

  1. Залогинься под пользователем vicky@gmail.com.
  2. Перейди по секретному адресу /hack/super_secret_qwe123 и получи токен.
  3. Перейди на главную страницу и убедись, что отображаются фотографии пользователя vicky@gmail.com.
  4. Убедись, что в качестве имени пользователя в правом верхнем углу главной страницы отображается vicky@gmail.com.
  5. Убедись, что доступна ссылка «Decode», как и всем разработчикам.
  6. Перейди по ссылке «Decode» и убедись, что пользователь (ClaimsPrincipal) представлен двумя личностями (ClaimsIdentity), одна из которых заполнена из токена, а другая — vicky@gmail.com.
  7. Убедись, что в качестве имени пользователя в правом верхнем углу страницы «Decode» отображается vicky@gmail.com.

6.3. Порядок схем аутентификации

А теперь еще немного теории про схемы аутентификации и политики авторизации.

При аутентификации в UseAuthentication создается единственная Identity с использованием схемы по умолчанию. Далее создается Principal, который содержит эту Identity, и сохраняется в User. При этом у Principal доступно свойство Claims, значение которого определяется Claims из этой единственной Identity. Затем эти Claims используются для авторизации.

Одним предложением: по схеме по умолчанию создается Identity, а затем используется при авторизации.

Но в этом случае, если какие-то claims надо взять из куки у схемы "Identity.Application", а другие из токена у схемы "Bearer", авторизация работать не будет. Потому что Identity строится с использованием одной схемы: схемы по умолчанию.

Замечание. По этой причине, добавление services.AddAuthentication().AddJwtBearer(...) в пункте 6.1 не привело к появлению ссылки Decode на главной странице. Роль Dev у пользователя обнаруживалась только в HackController.

Поэтому, если согласно политики для авторизации конкретного запроса требуется несколько схем, User, построенный в UseAuthentication, переопределяется в UseAuthorization. При этом для каждой схемы строится своя Identity, все они добавляются в Principal, который и сохраняется в User, а затем используется для авторизации.

Замечание. Это поведение было продемонстрировано в пункте 6.2.

Но остается нюанс: а что если один и тот же claim, например, name, определен сразу в нескольких Identity? Какое имя будет у пользователя? Ответ: claim из схемы, которая указана позже в определении политики, будут более приоритетны.

Например, пусть в схеме "Bearer" у пользователя имя Temporary Dev, а в схеме "Identity.Application"vicky@gmail.com. При использовании в политике следующего кода у пользователя будет имя vicky@gmail.com:

policyBuilder.AddAuthenticationSchemes("Bearer", "Identity.Application")

А при использовании такого кода у пользователя будет имя Temporary Dev:

policyBuilder.AddAuthenticationSchemes("Identity.Application", "Bearer")

При этом не должно смущать слово First в коде User.FindFirstValue(ClaimTypes.NameIdentifier), который можно прочесть как «найди у пользователя первый claim с типом nameidentifier». Просто чем ПОЗЖЕ идет схема в определении политики, тем РАНЬШЕ находится Identity этой схемы в Principal, т.е. в User.

Одним предложением: для каждой схемы из политики создается Identity и при авторизации используются они все, но схемы, идущие позже, более приоритетны.

Чтобы убедиться, что это работает именно так, можно посмотреть исходники ASP.NET Core на GitHub:

Замечание. Исходники на GitHub позволяют раскрыть многие нюансы работы ASP.NET Core, причем, в отличие от документации или статей, говорят правду и полностью. Более того, архитектура ASP.NET Core позволяет легко переопределять и расширять функциональность, и, как следствие, нужные исходники можно не только посмотреть и скачать, но и подключить, и отладить прямо в своем приложении. Отладка же позволяет сорвать все покровы. Искать исходники достаточно удобно с помощью Google: в запросе надо написать имя класса, иногда namespace, а также слово github и, возможно, asp.net core. Например, "AuthenticationMiddleware asp.net core github". Если же искать приходится часто, то репозиторий с ASP.NET Core можно просто склонировать: git clone https://github.com/dotnet/aspnetcore

А теперь для закрепления материала проведи несколько экспериментов:

  1. Запусти приложение, аутентифицируйся под пользователем vicky@gmail.com и с помощью /hack/super_secret_qwe123, т.е. по обеим схемам. Перейди на страницу «Decode» и проверь порядок Identity в User. Первой должна идти Identity для схемы "Identity.Application".

  2. В политике Dev поменяй местами схемы, чтобы JwtBearerDefaults.AuthenticationScheme была последней. То же самое сделай для DefaultPolicy. Запусти приложение, аутентифицируйся по обеим схемами. Обрати внимание, что имя пользователя на главной странице стало Temporary Dev. Перейди на страницу «Decode» и убедись, что порядок Identity в User тоже поменялся. Обрати внимание, что имя пользователя на странице «Dev» тоже стало Temporary Dev.

  3. Верни исходный порядок схем в политике DefaultPolicy. Запусти приложение, аутентифицируйся по обеим схемами. Обрати внимание, что ссылка «Decode» на главной странице доступна, а имя пользователя — vicky@gmail.com. Перейди на страницу «Decode» и обрати внимание, что имя пользователя отличается, т.е. Temporary Dev.

  4. А теперь закомментируй в политике Dev добавление разных схем, т.е. policyBuilder.AddAuthenticationSchemes. Запусти приложение, аутентифицируйся по обеим схемам. Обрати внимание, что ссылка «Decode» на главной странице доступна. Перейди по ней и убедись, что доступ к странице запрещен.

  5. Раскоментируй policyBuilder.AddAuthenticationSchemes в политике Dev и убери добавление схем в DefaultPolicy. Запусти приложение, аутентифицируйся по обеим схемам. Обрати внимание, что ссылка «Decode» исчезла с главной страницы. Переди напрямую на странцу /Dev/Decode: она будет доступна и на ней будет ссылка «Decode».

  6. Верни все, как было.

Полагаю, что с пунктами 1, 2 и 3 все понятно: на каждой странице действует некоторая политика и используется тот порядок Identity, который соответствует добавлению схем в этой политике.

А вот пункт 4 не так очевиден. На первый взгляд, страница «Decode» недоступна, потому что схему JwtBearerDefaults.AuthenticationScheme в политику не добавили. То, что схема JwtBearerDefaults.AuthenticationScheme при этом добавлена в DefaultPolicy значения не имеет. Хорошо. Но тогда почему доступна ссылка «Decode» на главной странице? Все потому, что на главной странице в User находятся Identity для обеих схем, как это указано в DefaultPolicy, а значит пользователь аутентифицирован и у него есть роль Dev. Ограничения политики Dev выполняются, пусть даже для этого использована «лишняя» схема.

Обратная ситуация в пункте 5. Было бы логично, чтобы ссылка на страницу «Decode» была доступна на главной. Но нет. На главной странице основная политика — DefaultPolicy, в которой в этом пункте подключена только одна схема. Поэтому в User находится только одна Identity, а значит у пользователя нет роли Dev.

В пунктах 4 и 5 поведение немного странное. Может быть оно изменится в будущем, но пока оно такое и соответствует исходникам.

Вывод: чтобы не получать странные спецэффекты, во всех политиках стоит использовать один и тот же набор схем аутентификации.

6.R. Резюме

Ты познакомился с JWT-токенами. Дальше познакомишься еще более плотно.

У тебя получилось собрать нестандартную аутентификацию из стандартных средств. JWT-токен штука стандартная, но обычно передается через Authorization Header, а не через cookie. А генерируется обычно в ходе специальных протоколов, например, в ходе OpenID Connect, который будет упоминаться через несколько заданий.

Теперь ты знаешь, что при авторизации можно использовать несколько схем аутентификации, каждая из которых будет давать пользователю соответствующие права.

Авторизация в ASP.NET Core содержит много нюансов и иногда может быть полезно залезть в исходники. А чтобы не получать странные спецэффекты, во всех политиках стоит использовать один и тот же набор схем аутентификации.

7. Авторизация в другом сервисе с помощью Client Credentials Flow

До сих пор бэкенд веб-приложения работал в рамках одного веб-сервера. В реальных веб-приложениях это обычно не так. Чаще всего есть некое веб-приложение, который отдает HTML-странички со скриптами и стилями, а большинство работы выполняется отдельными веб-сервисами. Такие веб-сервисы обычно находятся отдельно от веб-приложения: на другой виртуалке, на другой физической машине или даже в другом дата-центре.

Замечание. Не вдаваясь в четкое определение понятия «веб-приложение», здесь и далее под этим термином будет подразумеваться только веб-сервер, в котором выполняется программа, непосредственно взаимодействующая с браузером пользователя: отдающая разметку, стили, скрипты и предоставляющие API для скриптов. В нашем случае это PhotosApp.

Из-за разделения на веб-приложения и веб-сервисы возникает вопрос: как защитить веб-сервисы от запросов злоумышленников и дать доступ своим веб-приложениям? Понятно, что в том или ином виде нужна авторизация для собственных веб-приложений.

Один из способов — использовать API-ключи. Разработчики веб-сервиса некоторым образом передают разработчикам веб-приложения API-ключи, каждый из которых представляет собой уникальную строку. А затем при запросах от веб-приложения к веб-сервису передается подходящий API-ключ. Веб-сервис обнаруживает API-ключ в запросе и, если ключ еще действует, выполняет запрос.

Более продвинутый способ — использовать Client Credentials Flow протокола авторизации OAuth.

В этом случае кроме приложения и сервиса появляется еще одна сторона — сервер авторизации. Приложение регистрируется в качестве клиента в сервере авторизации. А сервер авторизации затем выдает правильным клиентам правильные права. Сервис тоже регистрируется в сервере авторизации, но в качестве ресурса. Сервис не получает каких-либо реквизитов, но по сути обязуется доверять решениям сервера авторизации.

При регистрации приложению выдается client id и password. При необходимости доступа к ресурсам, приложение предъявляет эти реквизиты серверу авторизации и запрашивает доступ к некоторому множеству ресурсов, т.е. сервисов. Если сервер авторизации согласен, то выдает некий «токен», который приложение может использовать для доступа к запрошенным ресурсам.

Токен похож на API-ключ, но между двумя схемами есть отличия:

  • В случае OAuth реквизиты клиента передаются только между клиентом и сервером авторизации. Между приложением и сервисом передаются только токены.
  • Токен обычно живет недолго, поэтому его потеря не так критична. API-ключи надо отзывать и получать заново.
  • Сервер авторизации становится единым удобным местом настройки прав доступа между приложениями и сервисами. Причем создать новую связь между зарегистрированным приложением и зарегистрированным сервисом легко.

Далее выделим PhotosService из PhotosApp и добавим между ними авторизацию по Client Credentials Flow.

7.1. Запуск отдельных сервисов

В этом задании понадобится запускать не только PhotosApp, но и PhotosService, и IdentityServer.

PhotosService — это сервис для хранения фотографий. Этот сервис реализует API, которое можно посмотреть в PhotosApiController. Делать запросы к этому API из PhotosApp можно с помощью RemotePhotosRepository. Это одна из реализаций IPhotosRepository, но в отличие от LocalPhotosRepository, который сейчас используется в PhotosApp, она не достает фотографии с диска, а делает HTTP-запросы к PhotosService. Сам PhotosService устроен максимально просто: получает HTTP-запросы и делегирует их выполнение своему LocalPhotosRepository. Который, в свою очередь, хранит данные также, как они сейчас хранятся в PhotosApp: информация в Sqlite, а файлы фотографий — в папке .photos.

IdentityServer — это реализация сервера авторизации, причем реализация «по умолчанию». Дело в том, что для .NET есть хорошая реализация сервера авторизации с OAuth и OpenID Connect — IdentityServer4. И, чтобы получить свой собственный сервер авторизации достаточно выполнить такие простые команды:

dotnet new -i IdentityServer4.Templates
dotnet new is4empty -n IdentityServer
dotnet sln add IdentityServer\IdentityServer.csproj

Первая команда подключает новые шаблоны для инструмента new, вторая создает пустой IdentityServer, а последняя подключает ее в solution. Для получения проекта IdentityServer уже были выполнены эти 3 команды, так что в твоем распоряжении уже есть рабочий сервер авторизации.

Для удобства в него также было добавлено логирование запросов с помощью middleware UseSerilogRequestLogging.

В перспективе этот сервер авторизации можно подключить к СУБД, добавить к нему UI, сконфигурировать так, как требуется. Хорошая архитектура с использованием Dependecy Inversion Principle, а также открытые исходные коды позволяют легко дополнять или переопределять функционал IdentityServer и делают его прекрасной основой для сервера авторизации на .NET.

Задания будут сфокусированы на использовании сервера авторизации, а не на его настройке. Но если понадобится настроить, то можно использовать документацию, более полные шаблоны для dotnet new: is4aspid с UI в виде ASP.NET Core Identity и is4ef с Entity Framework Core, а также исходные коды на GitHub.

Как же все эти сервисы запускать? Один из вариантов — все в IDE. Например, все запускать под отладкой в Rider, или в VS Code, или один какой-то сервис в Visual Studio, а другие два — в VS Code.

Но в целом достаточно будет под отладкой запускать какой-то один из сервисов, обычно PhotosApp, а остальные — просто через консоль.

Команды для запуска через консоль из папки с solution:

  • PhotosApp: dotnet run --project PhotosApp
  • PhotosService: dotnet run --project PhotosService
  • IdentityServer: dotnet run --project IdentityServer

Для запуска можно использовать файлы из папки launch. Например, для Windows файлы запуска такие:

  • ./launch/win/launchPhotosApp.cmd
  • ./launch/win/launchPhotosService.cmd
  • ./launch/win/launchIdentityServer.cmd

При любом варианте запуска у сервисов будут следующие адреса:

  • PhotosApp: https://localhost:5001
  • PhotosService: https://localhost:6001
  • IdentityServer: https://localhost:7001

Произведи тестовый запуск:

  1. В PhotosApp в Startup.cs замени использование локального репозитория на удаленный. Для этого закомментируй строчку с подключением LocalPhotosRepository и добавь строчку с подключением RemotePhotosRepository:
// services.AddScoped<IPhotosRepository, LocalPhotosRepository>();
services.AddScoped<IPhotosRepository, RemotePhotosRepository>();
  1. Запусти любым способом PhotosApp и убедись, что при открытии страницы приложения из RemotePhotosRepository выбрасывается исключение.

  2. Запусти любым способом PhotosService, обнови страницу в браузере — страница должна загрузиться, но без фотографий. Войди под vicky@gmail.com и убедись, что ее фотографии подгрузились из PhotosService.

  3. Запусти любым способом IdentityServer и убедись, что он запускается.

7.2. Client Credentials Flow

Для начала добавь аутентификацию по токену в PhotosService.

  1. Добавь в метод ConfigureServices в Startup.cs в проекте PhotosService следующий код:
services.AddAuthentication("Bearer")
    .AddJwtBearer("Bearer", options =>
    {
        options.Authority = "https://localhost:7001";
        options.Audience = "photos_service";
    });

В нем добавляется аутентификация по токену, причем будут приниматься только токены, которые изданы и подписаны сервером авторизации https://localhost:7001. А еще в этих токенах должно быть указано, что они дают доступ к ресурсу "photos_service". PhotosService как раз и будет этим «ресурсом».

  1. Добавь middleware для аутентификации и авторизации между UseRouting и UseEndpoints:
app.UseAuthentication();
app.UseAuthorization();
  1. Добавь атрибут [Authorize] контроллеру PhotosApiController.

Теперь запусти PhotosApp и PhotosService, зайди в приложение под vicky@gmail.com и убедись, что фотографии не загружаются, а вместо них надпись «Ничего не найдено». Да, RemoteRepository написан так, чтобы не падать, если доступ к фотографиям запрещен.

Далее надо зарегистрировать новый тип ресурсов в сервере авторизации.

Сейчас все ресурсы, доступ к которым можно получить через IdentityServer, перечислены в файле Config.cs. То, что используется именно это файл, сконфигурировано в Startup.cs в IdentityServer.

Итак, в файле Config.cs найди статическое свойство Apis и добавь туда запись о новом ресурсе:

new ApiResource("photos_service", "Сервис фотографий")
{
    Scopes = { "photos" }
}

Запись, которая там была в качестве примера, можно удалить.

В конфигурации ресурса прописано, что в нем есть некий scope "photos". Каждый ресурс может содержать несколько скоупов, в нашем случае один. Этот scope надо тоже зарегистрировать. Для этого в файле Config.cs найди статическое свойство ApiScopes и добавь туда запись о новом скоупе:

new ApiScope("photos", "Фотографии")

Запись, которая там была в качестве примера, можно удалить.

Идеологически ресурс соответствует некоторому API или сервису, а скоуп — некоторым возможностям в рамках сервиса. У нас все просто: один сервис — PhotosService, и одна возможность — работать с фотографими. А вот в сервисе Google Таблицы может быть много разных возможностей: посмотреть таблицы, отредактировать таблицы и так далее. Соотвественно, у каждого приложения могут быть разные права.

Обрати внимание, что ранее при настройке аутентификации PhotosService была строчка options.Audience = "photos_service". То есть сервис проверяет, что токен выдан для получения конкретного ресурса. А дальше в конфигурации клиента, т.е. веб-приложения, будет прописана строчка AllowedScopes = { "photos" }. То есть приложению выдается доступ к скоупу, некоторому функционалу в рамках ресурса.

Далее надо настроить, каким приложениям будет выдаваться доступ к новому ресурсу.

В файле Config.cs найди статическое свойство Clients и добавь туда запись о новом клиенте:

new Client
{
    ClientId = "Photos App by OAuth",
    ClientSecrets =
    {
        new Secret("secret".Sha256())
    },

    AllowedGrantTypes = GrantTypes.ClientCredentials,
    AllowedScopes = { "photos" }
}

Запись, которая там была в качестве примера, можно удалить.

В добавленной записи задается идентификатор и секрет клиента, используя которые приложение PhotosApp будет обращаться к серверу авторизации, описано, что общение между приложением и сервером будет происходить по Client Credential Flow (GrantTypes.ClientCredentials), а также описано, что при желании приложение сможет получить токен с доступом к скоупу "photos".

Теперь осталось только сконфигурировать приложение PhotosApp.

  1. Выполни в папке с solution:
dotnet add PhotosApp package IdentityModel

IdentityModel позволяет делать различные запросы по протоколам OAuth и OpenID Connect.

  1. Используя эту болванку, добавь метод GetAccessTokenByClientCredentialsAsync в RemotePhotosRepository, который должен получать токен для PhotosService от IdentityServer:
private static async Task<string> GetAccessTokenByClientCredentialsAsync()
{
    var httpClient = new HttpClient();
    // NOTE: Получение информации о сервере авторизации, в частности, адреса token endpoint.
    var disco = await httpClient.GetDiscoveryDocumentAsync("TODO: адрес сервера авторизации");
    if (disco.IsError)
        throw new Exception(disco.Error);

    // NOTE: Получение access token по реквизитам клиента
    var tokenResponse = await httpClient.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest
    {
        Address = disco.TokenEndpoint,
        ClientId = "TODO: идентификатор клиента",
        ClientSecret = "TODO: секрет без SHA-256 шифрования",
        Scope = "TODO: необходимые скоупы ресурсов через пробел"
    });

    if (tokenResponse.IsError)
        throw new Exception(tokenResponse.Error);

    return tokenResponse.AccessToken;
}
  1. В методе SendAsync в RemotePhotosRepository, который отправляет все запросы к PhotosService, добавь получение access token с помощью метода GetAccessTokenByClientCredentialsAsync. Полученный access token надо добавить в httpClient, чтобы все запросы отправлялись с токеном в заголовке Authorization вот так:
httpClient.SetBearerToken(accessToken);

Теперь на каждый запрос к PhotosService приложение PhotosApp будет получать access token от IdentityServer и отправлять его в запросе. А PhotosService будет проверять полученный access token и предоставлять доступ, т.к. доверяет токенам от IdentityServer.

Замечание. Запрашивать access token каждый раз, конечно, не рационально. Более правильный подход — знать время действия токена и запрашивать новый тогда, когда текущий заканчивается. Это несложно, но не хочется сейчас на это отвлекаться.

Убедись, что фотографии vicky снова отображаются после входа в PhotosApp.

7.3 Проверка токена

Как же PhotosService проверяет, что пришедшим ему токенам можно доверять? Просто. Он идет по адресу из options.Authority, в нашем случае это https://localhost:7001, получает открытый ключ и проверяет подпись токена. Кроме этого он проверяет, что время действия токена не прошло и что options.Audience, в нашем случае "photos_service", есть среди scope токена.

Проверь токен вручную!

Для этого поставь точку останова сразу после получения access token в методе SendAsync, запусти PhotosApp под отладкой и получи содержимое токена. Это содержимое вставь в качестве токена в сервис https://jwt.io/. Токен будет расшифрован и можно будет убедиться, что в нем есть значение "photos_service" в массиве scope, а также, что токен выдан на 1 час (сравни значения в exp и nbf).

Осталось проверить подпись:

  1. Перейди по адресу https://localhost:7001/.well-known/openid-configuration и увидишь подробную информацию о сервере авторизации. Именно эта информация получается в запросе GetDiscoveryDocumentAsync. Там есть адреса разных endpoints, но сейчас интересен адрес jwks_uri — перейди по нему.

  2. JWK — это Json Web Key. И по адресу из jwks_uri ты увидишь массив публичных ключей сервера авторизации в формате JSON. В нашем случае он будет включать только один ключ. У ключа будет kid, т.е. key identifier — это идентификатор ключа. Этот идентификатор также есть в header токена, чтобы было понятно с каким ключом проверять подпись. alg — это алгоритм шифрования, с которым можно использовать этот ключ. Этот алгоритм тоже указывается в header токена. e и n — это числа, образующие открытый ключ в RSA. Открой исходный код страницы, чтобы плагины в твоем браузере не мешали, и скопируй ключ в буфер обмена. Полностью весь JSON-объект, а не только e и n!

  3. Вставь скопированный JWK в поле для public key в сервисе https://jwt.io/. После этого должна появиться надпись «Signature Verified».

Ура, токен проверен!

7.R. Резюме

Ты научился пользоваться сервером авторизации в простейшем случае — Client Credentials Flow. Этот flow позволяет производить авторизацию приложения в других сервисах. И это довольно важный сценарий, хотя дальше будет показано, что можно авторизовать не приложение в сервисе, а сразу пользователя в сервисе.

Ты еще чуть лучше познакомился с JWT-токенами и научился их проверять.

Пожалуй сейчас сложность и функциональность аутентификации и авторизации, встроенной прямо в приложение близки к максимуму: вход по логину/паролю, отправка писем, вход через внешние провайдеры, настроенные политики авторизации, использование приложением других сервисов и авторизация в них. Куда еще сложнее?

В следующих заданиях произойдет постепенный переход от аутентификации, встроенной в приложение, к аутентификации через специальное приложение, а конкретно — через сервер авторизации.

8. Аутентификация с помощью OpenID Connect

OpenID Connect (кратко OIDC) — расширение OAuth, которое позволяет стандартным образом получать информацию о пользователе в виде id token от сервера авторизации для аутентификации пользователей. Далее предстоит подключить сервер авторизации Google по OpenID Connect, а также донастроить и подключить IdentityServer.

8.1. Шаг назад

В нескольких последующих заданиях нет необходимости в отдельном сервисе PhotosService. Чтобы не приходилось его запускать, подключи LocalPhotosRepository вместо RemotePhotosRepository в PhotosApp:

services.AddScoped<IPhotosRepository, LocalPhotosRepository>();
// services.AddScoped<IPhotosRepository, RemotePhotosRepository>();

Нет смысла удалять строчку с RemotePhotosRepository, потому что далее она пригодится.

8.2. Аутентификация через Google по OpenID Connect

Google уже поддерживает протокол OpenID Connect, поэтому можно подключить Google по OIDC, а не по OAuth.

Для этого найди код подключения Google и замени его вот так:

//.AddGoogle("Google", options =>
//{
//    options.ClientId = context.Configuration["Authentication:Google:ClientId"];
//    options.ClientSecret = context.Configuration["Authentication:Google:ClientSecret"];
//})
.AddOpenIdConnect(
    authenticationScheme: "Google",
    displayName: "Google",
    options =>
    {
        options.Authority = "https://accounts.google.com/";
        options.ClientId = context.Configuration["Authentication:Google:ClientId"];
        options.ClientSecret = context.Configuration["Authentication:Google:ClientSecret"];

        options.CallbackPath = "/signin-google";
        options.SignedOutCallbackPath = "/signout-callback-google";
        options.RemoteSignOutPath = "/signout-google";

        options.Scope.Add("email");
    });

Настроек стало больше, они стали более явными:

  • Authority — адрес сервера авторизации
  • CallbackPath — адрес, на который будет возвращаться пользователь после успешного входа. Адрес можно поменять в настройках клиента Google. Обработчик для запросов по этому пути будет добавлен за счет AddOpenIdConnect и делать что-то дополнительно не нужно.
  • SignedOutCallbackPath — адрес, на который будет возвращаться пользователь после успешного выхода из аккаунта Google. Обработчик для запросов по этому пути будет добавлен за счет AddOpenIdConenct. В любом случае с нашими настройками клиента таких запросов не будет.
  • RemoteSignOutPath — адрес, на который происходил бы переход после SignedOutCallbackPath.
  • Scope.Add("email") — дополнительно к скоупам oidc и profile запрашивается информация об email, которая полезна при создании аккаунта в Identity.

Проверь, что аутентификация через Google по OpenID Connect работает. Как будто ничего и не поменялось.

8.3. Indentity.External

Пришло время лучше разобраться как работает аутентификация с помощью внешних провайдеров при использовании Identity.

Когда подключается Identity с помощью AddDefaultIdentity происходит регистрация нескольких схем аутентификации, работающих по cookie. Основная из них уже знакомая "Identity.Application". Но при первом входе пользователя с помощью внешнего провайдера используется другая схема — "Identity.External".

Разберем на конкретном примере Google и OIDC, хотя логика одинаковая для всех внешних провайдеров аутентификации.

Пусть пользователь решил войти через Google по OIDC. Он перенаправляется на сервер авторизации Google, аутентифицируется там, а затем возвращается обратно в приложение по адресу /signin-google. Этот запрос обрабатывается и вся ценная информация о пользователе сохраняется в некоторую «другую схему аутентификации».

Что же это за схема? Это схема, которая была указана options.SignInScheme при конфигурировании AddOpenIdConnect. Но сейчас она не указана. Не проблема! Есть еще два кандидата, которые вот так регистрируются внутри AddDefaultIdentity:

services.AddAuthentication(o =>
    {
        o.DefaultScheme = IdentityConstants.ApplicationScheme;
        o.DefaultSignInScheme = IdentityConstants.ExternalScheme;
    })

DefaultSignInScheme — это более приоритетный кандидат в этом случае, а DefaultScheme — кандидат для «любой непонятной ситуации» с аутентификацией.

Таким образом в нашем случае вся ценная информация о пользователе, которая была получена от Google будет сохранена в схему "Identity.External" (это значение константы IdentityConstants.ExternalScheme), т.е. в cookie, связанную с этой схемой.

После этого пользователю предлагается создать аккаунт в приложении по схеме "Identity.Application" на основании данных из схемы "Identity.External", возможно подтвердить его через почту и войти. А cookie схемы "Indentity.External" по умолчанию существует совсем недолго и больше не используется.

При следующем входе через Google, например, после закрытия браузера, аккаунт в приложении уже существует и можно сразу использовать схему "Identity.Application", поэтому схема "Identity.External" не используется и cookie для нее не создается.

Убедись, что все работает именно так, как описано.

Для начала — подготовка.

После вызова AddDefaultIdentity явно настрой cookie схемы "Identity.External":

services.ConfigureExternalCookie(options =>
{
    options.Cookie.Name = "PhotosApp.Auth.External";
    options.Cookie.HttpOnly = true;
    options.ExpireTimeSpan = TimeSpan.FromMinutes(5);
    options.SlidingExpiration = true;
});

Здесь меняется имя куки, а остальные значения приведены для ознакомления и соответствуют значениям по умолчанию.

Понизь требования для захода на страницу Decode:

options.AddPolicy(
    "Dev",
    policyBuilder =>
    {
        policyBuilder.RequireAuthenticatedUser();
        //policyBuilder.RequireRole("Dev");
        //policyBuilder.AddAuthenticationSchemes(
        //    JwtBearerDefaults.AuthenticationScheme,
        //    IdentityConstants.ApplicationScheme);
    });

Закомментируй подключение SessionStore при вызове метода ConfigureApplicationCookie:

//options.SessionStore = serviceProvider.GetRequiredService<EntityTicketStore>();

Это нужно, чтобы посмотреть значения из cookie, а не просто идентификатор сессии.

Теперь можно проверять:

  1. Запусти приложение, открой его в браузере, зайди в него с помощью Google и создай аккаунт в приложении.
  2. Перейди на страницу «Decode» и убедись, что есть данные как из PhotosApp.Auth куки, так и из PhotosApp.Auth.External куки. Причем в PhotosApp.Auth.External данных значительно больше.
  3. Выйди из аккаунта с помощью кнопки «Logout», а затем снова войди с помощью Google.
  4. Перейди на страницу «Decode» и убедись, что данных в PhotosApp.Auth.External нет, как и самой куки.

Вывод: схема "Identity.External" используется только один раз при регистрации.

Еще один эксперимент. После входа пользователя Google передает данные в приложения в виде токенов, а уже затем информация из них сохраняется в cookie. Информацию из cookie уже посмотрели. А как посмотреть сам id token? С помощью настройки можно сделать так, чтобы исходные токены сохранялись в cookie.

Добавь в вызов AddOpenIdConnect настройку:

options.SaveTokens = true;

Зайди в приложение под аккаунтом Google и перейди на страницу «Decode». Теперь в PhotosApp.Auth.External ты увидишь id_token. Посмотри его содержимое с помощью https://jwt.io.

Теперь можешь закомментировать настройку options.SaveTokens: дальше не пригодится.

8.4. Генерация UI для IdentityServer

Сейчас IdentityServer отвечает только за выдачу токенов для доступа веб-приложений к веб-сервисам. Но к нему можно добавить пользовательский интерфейс для регистрации и входа пользователей. Тогда пользователи смогут регистрироваться и входить в IdentityServer, а IdentityServer сможет выдавать id token для доступа этих пользователей в сторонние приложения по OpenID Connect. Так же как это делает сервер авторизации Google.

Чтобы не делать UI с нуля, можно использовать готовый шаблон.

Установи шаблоны IdentityServer4 для dotnet CLI, выполнив в любой папке:

dotnet new -i IdentityServer4.Templates

Запусти генерацию UI в папку IdentityServer. Для этого из папки с solution выполни:

dotnet new is4ui -n IdentityServer

В результате в проекте IdentityServer должны появиться папки Quickstart и Views.

Чтобы добавленные представления и контроллеры в IdentityServer заработали, надо подключить MVC в Startup.cs. Для этого просто раскомментируй код в Startup.cs.

Также надо подключить к IdentityServer тестовых пользователей. Для этого добавь вызов AddTestUsers(TestUsers.Users) в цепочку вызовов после AddIdentityServer в Startup.cs:

var builder = services.AddIdentityServer()
    .AddInMemoryIdentityResources(Config.Ids)
    .AddInMemoryApiResources(Config.Apis)
    .AddInMemoryApiScopes(Config.ApiScopes)
    .AddInMemoryClients(Config.Clients);
    .AddTestUsers(TestUsers.Users); 

Запусти IdentityServer и убедись, что https://localhost:7001/ открывается без ошибок. Затем перейди по ссылке в тексте «Click here to manage your stored grants», и в открывшейся форме зайди под пользователем alice. Пароль от аккаунта можно посмотреть в файле IdentityServer/Quickstart/TestUsers.cs. После успешного входа ты узнаешь, что у Alice нет «given access to any applications».

8.5. Passport

Пусть способ входа в PhotosApp через IdentityServer называется «Passport». Теперь надо этот способ входа настроить.

В файле Config.cs найди статическое свойство Clients и добавь туда запись о новом клиенте:

new Client
    {
        ClientId = "Photos App by OIDC",
        ClientSecrets = { new Secret("secret".Sha256()) },

        AllowedGrantTypes = GrantTypes.Code,
        
        // NOTE: показывать ли пользователю страницу consent со списком запрошенных разрешений
        RequireConsent = false,

        // NOTE: куда отправлять после логина
        RedirectUris = { "https://localhost:5001/signin-passport" },

        AllowedScopes = new List<string>
        {
            // NOTE: Позволяет запрашивать id token
            IdentityServerConstants.StandardScopes.OpenId,
            // NOTE: Позволяет запрашивать профиль пользователя через id token
            IdentityServerConstants.StandardScopes.Profile,
            // NOTE: Позволяет запрашивать email пользователя через id token
            IdentityServerConstants.StandardScopes.Email
        },

        // NOTE: Надо ли добавлять информацию о пользователе в id token при запросе одновременно
        // id token и access token, как это происходит в code flow.
        // Либо придется ее получать отдельно через user info endpoint.
        AlwaysIncludeUserClaimsInIdToken = true,
    }

Этой записью добавляется клиент по Code Flow, т.е. так же, как был добавлен Google. Все остальное должно быть понятно из комментариев.

Так как предлагается давать доступ к скоупам profile и email, то надо определить, что это такое. Для этого файле Config.cs найди статическое свойство Ids и добавь туда новые наборы ресурсов:

new IdentityResources.Profile(),
new IdentityResources.Email()

Под каждым IdentityResource подразумевается некоторый скоуп, набор claims о пользователе, который клиент сможет получить через id token. Например, скоуп "email" включает в себя такие claims: "email", "email_verified".

Теперь IdentityServer настроен, осталось настроить приложение. Используй этот шаблон и настройки клиента, чтобы добавить новый способ аутентификации в IdentityHostingStartup.cs:

services.AddAuthentication()
    .AddOpenIdConnect("Passport", "Паспорт", options =>
    {
        options.Authority = "TODO: адрес сервера авторизации";

        options.ClientId = "TODO: идентификатор клиента";
        options.ClientSecret = "TODO: секрет без SHA-256 шифрования";
        options.ResponseType = "code";

        // NOTE: oidc и profile уже добавлены по умолчанию
        options.Scope.Add("TODO: запросить все доступные скоупы");

        options.CallbackPath = "TODO: куда отправлять после логина";

        // NOTE: все эти проверки токена выполняются по умолчанию, указаны для ознакомления
        options.TokenValidationParameters.ValidateIssuer = true; // проверка издателя
        options.TokenValidationParameters.ValidateAudience = true; // проверка получателя
        options.TokenValidationParameters.ValidateLifetime = true; // проверка не протух ли
        options.TokenValidationParameters.RequireSignedTokens = true; // есть ли валидная подпись издателя
    });

Запусти IdentityServer и PhotosApp, а затем войди через «Паспорт» аналогично тому, как раньше входил через Google. Вход должен работать, email при создании аккаунта подставляться из IdentityServer, а информация о профиле пользователя видна на странице «Decode» в PhotosApp.Auth.External куке.

8.R. Резюме

Ты научился создавать свой сервер авторизации, работающий по протоколу OpenID Connect и подключил его приложению в качестве внешнего провайдера аутентификации.

9. Аутентификация только через IdentityServer

На данный момент было продемонстрировано два разных подхода к аутентификации:

  • аутентификация внутри приложения (с использованием Identity)
  • аутентификация через внешнего провайдера (Google, Passport)

Для отдельных веб-приложений — идеальнй подход.

Но для компании, которая выпускает множество веб-приложений, есть смысл завести собственный IdentityServer. В этом случае пользователи все-таки будут регистрироваться внутри компании, этими учетными записями можно будет удобно управлять в одном месте, давая права, назначая тарифы. Но при этом входить пользователю придется один раз в IdentityServer компании, а дальше все веб-приложения компании будут его узнавать.

Пойдем по пути отдельного IdentityServer, тем более что многое уже готово: IdentityServer есть, схема Passport создана. Нужно только перенести пользователей из приложения в сервер авторизации, удалить Identity и еще кое-какие нюансы.

9.1. Перенос пользователей в IdentityServer

Начни с переноса всех пользователей PhotosApp в IdentityServer.

В файле IdentityServer/Quickstart/TestUsers.cs добавь записи о пользователях из PhotosApp:

new TestUser
{
    SubjectId = "a83b72ed-3f99-44b5-aa32-f9d03e7eb1fd",
    Username = "vicky@gmail.com",
    Password = "Pass!2",
    Claims =
    {
        new Claim(JwtClaimTypes.Name, "vicky@gmail.com"),
        new Claim(JwtClaimTypes.Email, "vicky@gmail.com"),
        new Claim("testing", "beta"),
    }
},
new TestUser
{
    SubjectId = "dcaec9ce-91c9-4105-8d4d-eee3365acd82",
    Username = "cristina@gmail.com",
    Password = "Pass!2",
    Claims =
    {
        new Claim(JwtClaimTypes.Name, "cristina@gmail.com"),
        new Claim(JwtClaimTypes.Email, "cristina@gmail.com"),
        new Claim("subscription", "paid"),
    }
},
new TestUser
{
    SubjectId = "b9991f69-b4c1-477d-9432-2f7cf6099e02",
    Username = "dev@gmail.com",
    Password = "Pass!2",
    Claims =
    {
        new Claim(JwtClaimTypes.Name, "dev@gmail.com"),
        new Claim(JwtClaimTypes.Email, "dev@gmail.com"),
        new Claim("subscription", "paid"),
        new Claim("role", "Dev")
    }
}

Обрати внимание, что были перенесены также все утверждения о пользователях, которые должны им давать некоторые права.

Теперь можно зайти на IdentityServer под этими пользователями, но никаких особых прав у них в PhotosApp не будет, ведь там все до сих пор построено на Identity.

9.2. Отключение Identity

Пришло время окончательно отказаться от Identity в PhotosApp в пользу IndetityServer. Заодно и от других способов аутентификации.

Прежде всего придется закомментировать или удалить много кода! Удалять много не хочется, чтобы тратить минимум времени на ошибки компиляции, но в реальном приложении, конечно, надо было бы сделать полную зачистку от неиспользуемого кода.

В IdentityHostingStartup.cs очень мало полезного кода. Перенеси, то перечислено в конец ConfigureServices в Startup.cs:

  • Подключение Passport:
services.AddAuthentication()
    .AddOpenIdConnect("Passport", "Паспорт", options =>
    {
        ...
    }
  • Подключение MustOwnPhotoHandler
services.AddScoped<IAuthorizationHandler, MustOwnPhotoHandler>();
  • Политики авторизации services.AddAuthorization, но исправь политику по умолчанию вот так:
options.DefaultPolicy = new AuthorizationPolicyBuilder()
    .RequireAuthenticatedUser()
    .Build();

Теперь можно удалить файл IdentityHostingStartup.cs.

В Data/PhotosAppDataExtensions.cs в методе PrepareData закомментируй все, что связано с UsersDbContext, TicketsDbContext, RoleManager и UserManager.

В Startup.cs удали вызов endpoints.MapRazorPages();, потому что они использовались только для Identity.

Разборка на этом закончена. Теперь надо собрать новую аутентификацию.

Прежде всего надо создать cookie-схему, в которую внешние провайдеры, а именно OpenID Connect, смогут сохранять свою информацию. Поэтому добавь в Startup.cs в PhotosApp:

services.AddAuthentication(options =>
    {
        // NOTE: Схема, которую внешние провайдеры будут использовать для сохранения данных о пользователе
        // NOTE: Так как значение совпадает с DefaultScheme, то эту настройку можно не задавать
        options.DefaultSignInScheme = "Cookie";
        // NOTE: Схема, которая будет вызываться, если у пользователя нет доступа
        options.DefaultChallengeScheme = "Passport";
        // NOTE: Схема на все остальные случаи жизни
        options.DefaultScheme = "Cookie";
    })
    .AddCookie("Cookie", options =>
    {
        // NOTE: Пусть у куки будет имя, которое расшифровывается на странице «Decode»
        options.Cookie.Name = "PhotosApp.Auth";
        // NOTE: Если не задать здесь путь до обработчика logout, то в этом обработчике
        // будет игнорироваться редирект по настройке AuthenticationProperties.RedirectUri
        options.LogoutPath = "/Passport/Logout";
    });

Замечание. Возможно у тебя возникают вопросы вроде: «А как узнать, что надо использовать опцию LogoutPath, чтобы logout работал корректно?». Ответ такой: «Да никак! Надо открыть исходники ASP.NET Core на GitHub, найти класс CookieAuthenticationHandler и прочитать какие опции на что влияют».

Затем создай вот такой контроллер, чтобы инициировать вход пользователя и обработать выход пользователя:

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace PhotosApp.Controllers
{
    public class PassportController : Controller
    {
        // NOTE: Неаутентифицированный пользователь будет отправляться на вход в DefaultChallengeScheme,
        // а затем возвращаться сюда и отсюда перенаправляться на исходную страницу из returnUrl.
        public IActionResult Login(string returnUrl)
        {
            if (!User.Identity.IsAuthenticated)
                return Challenge();
            
            return Redirect(Url.IsLocalUrl(returnUrl) ? returnUrl : "/");
        }

        // NOTE: Выход из текущей схемы аутентификации с последующей переадресацией
        [Authorize]
        public IActionResult Logout()
        {
            return SignOut(new AuthenticationProperties
            {
                RedirectUri = "/"
            });
        }
    }
}

Наконец, создай ссылки для входа. Для этого замени содержимое Views/Shared/_LoginPartial.cshtml на такое:

@using System.Security.Claims
@using Microsoft.AspNetCore.Http.Extensions

<ul class="navbar-nav">
@if (User.Identity.IsAuthenticated)
{
    <li class="nav-item">
        <span class="nav-link text-dark">Hello @User.FindFirstValue(ClaimTypes.Email)!</span>
    </li>
    <li class="nav-item">
        <form id="logoutForm" class="form-inline"
            asp-controller="Passport"
            asp-action="Logout">
            <button id="logout" type="submit" class="nav-link btn btn-link text-dark">Logout</button>
        </form>
    </li>
}
else
{
    <li class="nav-item">
        <form id="loginForm" class="form-inline"
            asp-controller="Passport"
            asp-action="Login"
            asp-route-returnUrl="@Context.Request.GetEncodedPathAndQuery()">
            <button id="login" type="submit" class="nav-link btn btn-link text-dark">Login</button>
        </form>
    </li>
}
</ul>

А в Views/Photos/Index.cshtml замени код

<a asp-area="Identity" asp-page="/Account/Login">

на

<a asp-controller="Passport" asp-action="Login">

Теперь можно убедиться, что аутентификация через IdentityServer работает!

9.3. Logout из внешнего провайдера

Что должно происходить, когда пользователь, вошедший через внешнего провайдера хочет выйти? Надо просто выйти из приложения или еще выйти во внешнем провайдере? Если пользователь зашел с помощью Google и хочет выйти из приложения PhotosApp, то скорее всего надо просто выйти из приложения. Но если он нажал выход в приложении Google Photos, то пожалуй он хочет выйти из своего Google-аккаунта.

Реализуй для Passport подобное поведение: пусть пользователь, вошедший через Passport, при выходе из PhotosApp также выходит из Passport.

Первый шаг — заменить содержимое метода Logout в PassportController на такое:

public IActionResult Logout()
{
    return SignOut(new AuthenticationProperties
    {
        RedirectUri = "/"
    }, "Cookie", "Passport");
}

Фактически вместо выполнения выхода только из схемы «по умолчанию», т.е. "Cookie", будет выполняться выход сразу из двух схем: "Cookie" и "Passport".

Теперь можешь убедиться, что при нажатии на кнопку «Logout» происходит выход из приложения, а затем выход в IdentityServer. При этом пользователь остается на странице IdentityServer.

Это как-то неудобно. Правильнее было бы вернуть пользователя на главную страницу PhotosApp. Так можно сделать, но есть «подводные камни».

Во-первых, в настройках в вызове AddOpenIdConnect для Passport надо задать адрес, на который надо возвращать пользователя после выхода.

options.SignedOutCallbackPath = "/signout-callback-passport";

Строго говоря, у SignedOutCallbackPath уже было задано значение /signout-callback-oidc по умолчанию. Но лучше значением по умолчанию не пользоваться: если в приложение добавить несколько OIDC, то при настройках по умолчанию непонятно кто будет обрабатывать запрос на выход.

Явного задания SignedOutCallbackPath недостаточно. Да, приложение предложит IdentityServer сделать редирект по этому адресу. Но IdentityServer не будет делать редирект непонятно куда.

Поэтому, во-вторых, надо зарегистрировать этот адрес в клиенте "Photos App by OIDC" в Config.cs в IdentityServer:

// NOTE: куда предлагать перейти после логаута
PostLogoutRedirectUris = { "https://localhost:5001/signout-callback-passport" },

Можешь убедиться, что так тоже работать не будет. Все дело в том, что IdentityServer требует предъявить при выходе некоторый id token пользователя в качестве «подсказки», т.е. передать в запросе id_token_hint.

Обработчик OpenID Connect умеет отправлять id_token_hint, если id token доступен в сессии, т.е. в нашем случае лежит в куке схемы "Cookie". Проблема в том, что его там пока нет.

Поэтому, в-третьих, надо добавить сохранение токенов в вызов AddOpenIdConnect для Passport:

options.SaveTokens = true;

Вот теперь можно убедиться, что выход из приложения работает правильно: происходит редирект на страницу выхода в IdentityServer, на которой есть ссылка для возврата в приложение PhotosApp.

9.4. Долгий login при использовании внешнего провайдера

При использовании внешнего провайдера фактически существуют две сессии: сессия в сервере авторизации и сессия в приложении. Как должно быть связано время жизни этих сессий без учета явного логаута?

Возможны разные варианты:

  1. Сессия в приложении существует только до закрытия браузера. При следующем открытии приложения в браузере надо будет нажать на кнопку входа и, если сессия в сервере авторизации существует, то происходит вход без ввода логина-пароля. Недостаток подхода в необходимости каждый раз заново входить.

  2. Сессия в приложении существует некоторое заданное время, которое никак не зависит от сервера авторизации. Сервер авторизации используется только для того, чтобы убедиться, что пользователь существует. Недостаток в том, что если пользователь впоследствии будет удален на сервере авторизации, он все еще некоторое время сможет пользоваться приложением.

  3. Если пользователь при входе в приложении выбрал опцию «запомнить меня», то используется вариант 2, а иначе вариант 1. Минус в том, что пользовательский интерфейс становится немного сложнее.

  4. Сессия привязана ко времени действия id token id token, который выдает сервер авторизации. Когда же время действия id token заканчивается, то с помощью скрытого iframe со специальным параметром propmt=none из браузера запрашивается id token. Если пользователь в сервере авторизации выбрал опцию «запомнить меня», то новый id token будет выдан успешно и можно продолжать работать. Этот подход называется Silent Renew. Недостаток в том, что подход сложнее реализовать и делать это надо на стороне браузера. Благо писать много кода не надо, ведь можно воспользоваться готовой библиотекой, например oidc-client.js.

Чтобы не усложнять интерфейс и не писать логику на стороне браузера, реализуй вариант 2.

Для этого сначала поменяй метод Login в PassportController так:

public IActionResult Login(bool rememberMe, string returnUrl)
{
    if (!User.Identity.IsAuthenticated)
    {
        // NOTE: с помощью properties можно задать некоторые параметры будущей сессии.
        // Основные же параметры сессии будут созданы внешним провайдером при обработке Challenge.
        var properties = rememberMe
            ? new AuthenticationProperties
                {
                    // NOTE: Кука будет сохраняться при закрытии браузера
                    IsPersistent = true,
                    // NOTE: Кука будет действовать 7 суток
                    ExpiresUtc = DateTime.UtcNow.AddDays(7),
                }
            : null;

        return Challenge(properties);
    }
    
    return Redirect(Url.IsLocalUrl(returnUrl) ? returnUrl : "/");
}

Предложенная реализация довольно универсальна и подходит для варианта 3 за счет параметра rememberMe. И если бы в интерфейсе был переключатель «запомнить меня», то его значение можно было бы легко пробросить.

Теперь сделай так, чтобы из пользовательского интерфейса при логине всегда пробрасывалось rememberMe=true.

Для этого в Views/Shared/_LoginPartial.cshtml внутри <form> с кнопкой логина добавь такой код:

<input id="rememberMe" name="rememberMe" type="hidden" value="true" />

А в Views/Photos/Index.cshtml замени код

<a asp-controller="Passport" asp-action="Login">

на

<a asp-controller="Passport" asp-action="Login" asp-route-rememberMe="true">

Можешь убедиться, что теперь после закрытия браузера сессия не теряется и заново входить не приходится.

9.5. Отмена логина

А что если пользователь попытается войти, откроет форму входа в сервере авторизации и... нажмет «Cancel»?

Сейчас вылетит ошибка, ведь PhotosApp к такому повороту событий не готово.

Благо это нетрудно поправить, добавив в конфигурацию Passport обработку неудачного логина вот так:

options.Events = new OpenIdConnectEvents
{
    OnRemoteFailure = context => 
    {
        context.Response.Redirect("/");
        context.HandleResponse();
        return Task.CompletedTask;
    }
};

Проверь, что теперь при нажатии на «Cancel» происходит переход на главную страницу PhotosApp.

9.6. Claims из внешнего провайдера

Пользователи из IdentityServer могут входить в PhotosApp, но функционал приложения для них ограничен, потому что у них нет ни доступа к бета-версиям, ни платной подписке. Да, нужные утверждения для пользователей были перенесены в IdentityServer, но эта информация не передается в PhotosApp. Надо это исправить.

Чтобы нестандартные claims можно было запросить, нужно создать новый scope для id token. Для этого в файле Config.cs добавь новый IdentityResource с названием photos_app:

new IdentityResource("photos_app", "Web Photos", new []
{
    "role", "subscription", "testing"
})

Дай клиенту "Photos App by OIDC" доступ к новому scope photos_app, прописав его в AllowedScopes.

Теперь в PhotosApp сделай так, чтобы scope photos_app запрашивался при аутентификации через Passport. Это можно сделать аналогично scope email.

Теперь проверка.

Зайди в приложение под пользователем vicky через Passport. Убедись, что ей доступно изменение подписи фотографии и оно работает. Перейди на страницу «Decode» и убедись, что в User есть claim testing. Данные о User — в самом низу страницы.

Зайди в приложение под пользователем dev через Passport. Убедись, что ему доступно добавление фотографий и оно работает. Перейди на страницу «Decode» и убедись, что в User есть claim subscription, а также claim rolehttp://schemas.microsoft.com/ws/2008/06/identity/claims/role.

9.R. Резюме

Ты перешел от встроенной аутентификации к аутентификации через собственный сервер авторизации. И получил вполне работающий вариант — пользователь может войти, выйти, а также действуют политики авторизации.

10. Авторизация в другом сервисе с помощью Authorization Code Flow

Client Credentials Flow позволяет защищать сервисы от запросов неизвестных приложений. Но если приложение имеет доступ к сервису, то оно получает доступ к данным всех пользователей в этом сервисе. Более правильный подход — выдавать access token для конкретного пользователя, чтобы приложение могло совершать в сервисе действия только с данными этого конкретного пользователя.

Code Flow и Implicit Flow в OAuth и соответствующие Flow в OpenID Connect позволяют получать такие access token.

Далее надо научиться получать в PhotosApp access token для пользователя от IdentityServer, чтобы затем авторизоваться с этими токенами в PhotosService.

10.1. Шаг вперед

Далее вновь потребуется PhotosService. Поэтому снова подключи RemotePhotosRepository:

// services.AddScoped<IPhotosRepository, LocalPhotosRepository>();
services.AddScoped<IPhotosRepository, RemotePhotosRepository>();

10.2. Авторизация по access token пользователя

Ресурс photos_service уже существует в IdentityServer и access tokens, выпущенные с audience photos_service и scope photos будут приниматься в PhotosService. Это упрощает задачу.

Чтобы IdentityServer присылал правильный access token вместе с id token:

  1. В PhotosApp в конфигурации Passport добавь "photos" в options.Scope
  2. В IdentityServer добавь "photos" в AllowedScopes клиента "Photos App by OIDC"

Хорошо бы явно спросить хочет ли пользователь давать PhotosApp разрешение на доступ к PhotosService. Поэтому в настройках клиента "Photos App by OIDC" замени значение настройки RequireConsent на true. После этого IdentityServer начнет показывать страницу со списком запрашиваемых приложением разрешений.

В одном из предыдущих заданий уже было реализовано сохранение токенов, пришедших от внешних провайдеров (options.SaveTokens = true), поэтому access token также будет сохраняться.

А это значит, что осталось только в RemotePhotosRepository передавать access token пользователя вместо access token, запрашиваемого по Client Credentials Flow.

Для этого поправь метод SendAsync в RemotePhotosRepository вот так:

private async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request)
{
    var httpContext = httpContextAccessor.HttpContext;
            
    var accessToken = await httpContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken);
    if (accessToken == null)
        return new HttpResponseMessage(HttpStatusCode.Unauthorized);

    var httpClient = new HttpClient();
    httpClient.SetBearerToken(accessToken);
    var response = await httpClient.SendAsync(request);
    return response;
}

Чтобы этот метод работал надо с помощью Dependency Injection через конструктор получить IHttpContextAccessor и сохранить ее в поле httpContextAccessor.

После выполнения этих действий убедись, что пользователь cristina при входе через Passport видит страницу со списком разрешений, а после входа может смотреть и добавлять фотографии. Также странице «Decode» видно, что в куке PhotosApp.Auth появился access token.

10.3. Контроль доступа с помощью access token

Сейчас каждый access token, приходящий в PhotosService создержит информацию о пользователе, в частности, его идентификатор. Этого достаточно, чтобы PhotosService мог предоставить пользователю доступ к его фотографиям и запретить к ним доступ для всех других пользователей. Следовательно, можно отказаться от политики MustOwnPhoto в пользу access token.

Закомментируй атрибут [Authorize(Policy = "MustOwnPhoto")] в PhotosController.

А теперь поставь эксперимент:

  1. Зайди под cristina через Passport.
  2. Перейди на страницу с любой одной фотографией и сохрани URL страницы
  3. Выполни logout и зайди под пользователем vicky
  4. Используй сохраненный URL, чтобы открыть фотографию. Она снова будет доступна.

А теперь надо настроить использование access token, чтобы вновь фотографии пользователя были доступны только самому пользователю.

Все, что надо сделать — это получить access token в методах PhotosApiController и добавить проверку владельца. Например, в методе GetPhotoContent проверка могла бы выглядеть примерно так, если предположить, что в accessToken.Subject находится идентификатор пользователя:

var photoEntity = await photosRepository.GetPhotoMetaAsync(id);
if (photoEntity == null)
    return NotFound();
if (accessToken.Subject != photoEntity.OwnerId)
    return Forbid();

Но как получить токен? Есть способ, который уже использовался:

var accessToken = await HttpContext.GetTokenAsync("access_token");

Рабочий способ. Недостаток только в том, что вернется строка, которую еще надо будет десериализовать. Конечно, для десериализации можно будет использовать что-то готовое, ведь раз ASP.NET Core как-то достает информацию из токенов, значит есть способ.

Другая идея — ModelBinder. Специальный код, который из запроса достанет нужную информацию и положит в качестве параметра в метод контроллера. Чтобы получилось как-то так:

GetPhotoContent(Guid id, JwtSecurityToken accessToken)

Причем вообще-то ASP.NET Core действительно десериализует access token, чтобы его проверить. И после проверки сообщает об этом с помощью события. Как следствие в опциях AddJwtBearer можно написать код перехвата токена после проверки:

options.Events = new JwtBearerEvents
{
    OnTokenValidated = context =>
    {
        // NOTE: с полученным токеном можно что-то сделать
        var accessToken = context.SecurityToken;
        // NOTE: например, сохранить куда-нибудь в HttpContext, чтобы потом достать в контроллере
        var httpContext = context.HttpContext;
        return Task.CompletedTask;
    }
};

Воспользуйся вторым способом с перехватом и ModelBinder. Для этого:

  1. В Startup.cs в PhotosServices дополни опции вызова AddControllers:
services.AddControllers(options =>
{
    options.ReturnHttpNotAcceptable = true;
    // NOTE: Существенно, что новый провайдер добавляется в начало списка перед провайдером по умолчанию
    options.ModelBinderProviders.Insert(0, new JwtSecurityTokenModelBinderProvider());
})
  1. В том же файле добавь в AddJwtBearer такой код:
options.Events = new JwtBearerEvents
{
    OnTokenValidated = context =>
    {
        JwtSecurityTokenModelBinder.SaveToken(context.HttpContext, context.SecurityToken);
        return Task.CompletedTask;
    }
};
  1. Найди JwtSecurityTokenModelBinderProvider и JwtSecurityTokenModelBinder и посмотри как они работают.

  2. Теперь вот этот код для GetPhotoContent действительно будет работать:

[HttpGet("{id}/content")]
public async Task<IActionResult> GetPhotoContent(Guid id, JwtSecurityToken accessToken)
{
    var photoEntity = await photosRepository.GetPhotoMetaAsync(id);
    if (photoEntity == null)
        return NotFound();
    if (accessToken.Subject != photoEntity.OwnerId)
        return Forbid();

    ...
}

Используй его, чтобы защитить GetPhotoContent.

Теперь защити все оставшиеся методы PhotosApiController. Идея простая: если ownerId не совпадает с accessToken.Subject, то надо вернуть Forbid().

Замечание. JwtSecurityToken — это сложный тип, поэтому по умолчанию ASP.NET Core пытается значение этого типа получить из body запроса. Для JwtSecurityTokenModelBinder это не проблема, пока не появится другой параметр со сложным типом, значение которого надо получать из body. Как, например, в методах AddPhoto и UpdatePhoto. Поэтому в этих методах придется accessToken помечать атрибутом [FromHeader]:

public async Task<IActionResult> AddPhoto(PhotoToAddDto photo, [FromHeader] JwtSecurityToken accessToken)

Повтори эксперимент с cristina и vicky. Теперь у vicky не должна открываться фотография cristina.

10.4. Refresh token

Текущая схема с access-токенами не учитывает одного важного нюанса — обычно access-токен имеет небольшое время жизни. А все потому, что access-токены обычно нельзя отозвать: если сервису предъявили подписанный действующий access-токен, то у сервиса нет причин отказать в доступе. Даже если пользователь через сервер авторизации уже запретил доступ для приложения, которое успело получить access-токен. Раз нельзя отозвать, то пусть хоть действует недолго.

Но из этого следует, что нужно каким-то образом уметь получать новый access-токен, когда старый перестанет действовать. И не отвлекать на это каждые 5 минут пользователя, который увлеченно использует приложение, которому предоставил доступ.

И есть два распространенных подхода: Silent Renew, который применяется на стороне браузера, и Refresh token, который можно применять и на стороне браузера и на стороне сервера, но требует больше гарантий от веб-приложения.

Так как сейчас страницы веб-приложения генерируются на сервере, то Silent Renew не подходит. Так что надо разобраться и воспользоваться Refresh token.

Refresh-токен — это специальный токен для получения новых access-токенов. Приложение может запросить refresh-токен вместе с access-токеном и, если пользователь даст разрешение, то приложение его получит. Используя refresh-токен приложение может получить access-токен, когда старый перестанет действовать или даже раньше. Сам же refresh-токен имеет продолжительный срок жизни, это могут быть месяцы. Более того, вместе с новым access-токеном обычно приходит новый refresh-токен, поэтому refresh-токен в приложении можно постоянно обновлять, а значит он будет всегда действителен.

Важной особенностью refresh-токена является возможность его отозвать: если пользователь сообщит серверу авторизации, что больше не хочет предоставлять доступ приложению, то сервер авторизации больше не будет выдавать по refresh-токенам приложения новые access-токены.

Итоговая схема с access-токенами и refresh-токенами такая:

  • есть короткоживущие access-токены для доступа к некоторым ресурсам, проверка которых не требует запроса к серверу авторизации, что хорошо для производительности.
  • есть долгоживущие refresh-токены, которые позволяют получать через запрос к серверу авторизации новые access-токены, при этом их можно отозвать.

При использовании refresh-токен важно учитывать два момента:

  • Refresh-токен дает приложению возможность получать access-токены и делать запросы от имени пользователя в любое время, даже когда пользователь находится offline. Так что, по идее, пользователь должен полностью доверять приложению, перед тем как даст приложению разрешение на получение refresh-токена.
  • Refresh-токен должен храниться в максимально безопасном месте. Лучшее место — серверная сторона веб-приложения. Тем более, что именно в таком случае refresh-токен можно будет использовать для действий без участия пользователя.

Замечание. Также на практике применяется схема с access-токенами, которые можно отозвать. В этом случае access-токен проверяется на сервере авторизации при каждом запросе, либо раз в некоторое время. Это может пригодится в приложениях, где время отзыва критично.

Пришло время подключить refresh-токены.

Для начала усугуби проблему. Сделай так, что access-токены от IdentityServer были действительны только полминуты. Для этого в Config.cs в настройках клиента "Photos App by OIDC" добавь такую опцию:

AccessTokenLifetime = 30

При проверке времени действия токена есть допустимая погрешность. Она теперь будет мешать, поэтому выстави ее в 0. Для этого в PhotosService в Startup.cs в опциях AddJwtBearer пропиши:

options.TokenValidationParameters.ClockSkew = TimeSpan.Zero;

Теперь войди под пользователем vicky@gmail.com через Passport. На главной странице должны подгрузиться фотографии. Подожди полминуты и обнови главную страницу. Фотографии должны пропасть, т.к. access token перестал действовать.

Проблема очевидна. И пришло время ее решить.

Для этого надо, чтобы приложение запрашивало refresh-токен, а сервер авторизации его предоставлял.

  1. Добавь в настройках Passport в PhotosApp такую опцию:
options.Scope.Add("offline_access");
  1. Добавь в настройках клиента "Photos App by OIDC" в IdentityServer такую опцию:
AllowOfflineAccess = true

После этих действий refresh-токен будет получаться вместе с access-токеном, а в нашем случае и сохраняться в куке благодаря настройке options.SaveTokens = true.

Осталось научиться получать access-токены с помощью refresh-токенов. Пусть стратегия будет «наивной»: если на запрос из RemotePhotosRepository приходит ответ 401 Unauthorized, то наверное access token больше не действует и надо запросить новый, а затем повторить запрос. Если повторный запрос провалился, то тут уже делать ничего не надо.

В соответствие с этой логикой метод SendAsync в RemotePhotosRepository может быть таким:

private async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request)
{
    var httpContext = httpContextAccessor.HttpContext;
    
    var accessToken = await httpContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken);
    if (accessToken == null)
        return new HttpResponseMessage(HttpStatusCode.Unauthorized);

    var httpClient = new HttpClient();
    request.SetBearerToken(accessToken);
    var response = await httpClient.SendAsync(request);
    if (response.StatusCode != HttpStatusCode.Unauthorized)
        return response;

    var refreshToken = await httpContext.GetTokenAsync(OpenIdConnectParameterNames.RefreshToken);
    if (refreshToken == null)
        return new HttpResponseMessage(HttpStatusCode.Unauthorized);

    // NOTE: запрос нового access token
    var newAccessToken = await RefreshAccessTokenAsync(refreshToken);
    if (newAccessToken != null)
    {
        // NOTE: повторный запрос
        var newHttpClient = new HttpClient();
        // NOTE: HttpRequestMessage нельзя использовать два раза, поэтому он копируется
        var secondRequest = await request.CopyAsync();
        secondRequest.SetBearerToken(newAccessToken);
        var secondResponse = await newHttpClient.SendAsync(secondRequest);
        return secondResponse;
    }

    return new HttpResponseMessage(HttpStatusCode.Unauthorized);
}

private async Task<string> RefreshAccessTokenAsync(string refreshToken)
{
    throw new NotImplementedException();
}

Но как реализовать RefreshAccessTokenAsync? Для получения access token нужно сделать запрос к token endpoint у IdentityServer. Это означает, что надо получить информацию про IdentityServer. Ту самую, которую ты раньше уже получал вручную по адресу https://localhost:7001/.well-known/openid-configuration.

Для этого можно использовать NuGet-пакет IdentityModel: там есть специальный класс DiscoveryCache, который умеет не только получать настройки, но и кэшировать их, чтобы не запрашивать их при каждом запросе.

Но есть еще один вариант: получать конфигурацию также, как это происходит при подключении аутентификации через AddOpenIdConnect. Понятно же, что раз приложение уже получает какие-то токены от IdentityServer, то оно умеет и его конфигурацию получать. Отвечает за получение и кэширование конфигурации тип IConfigurationManager<OpenIdConnectConfiguration>.

Его экземпляр создается глубоко внутри AddOpenIdConnect, но можно создать свой экземпляр и передать внутрь AddOpenIdConnect — тогда внутри IConfigurationManager создаваться не будет. А значит кэш с настройками будет существовать в единственном экземпляре.

Создай и зарегистрируй в services свой configurationManager в файле Startup.cs в PhotosApp перед конфигурированием Passport вот так:

const string oidcAuthority = "https://localhost:7001";
var oidcConfigurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(
    $"{oidcAuthority}/.well-known/openid-configuration",
    new OpenIdConnectConfigurationRetriever(),
    new HttpDocumentRetriever());
services.AddSingleton<IConfigurationManager<OpenIdConnectConfiguration>>(oidcConfigurationManager);

А в вызове AddOpenIdConnect на месте конфигурирования options.Authority напиши так:

options.ConfigurationManager = oidcConfigurationManager;
options.Authority = oidcAuthority;

Теперь можно получить IConfigurationManager<OpenIdConnectConfiguration> oidcConfigurationManager через конструктор RemotePhotosRepository и сохранить в поле для использования в методах.

Наконец, можно написать реализацию RefreshAccessTokenAsync, которая будет использовать IConfigurationManager для получения настроек и NuGet-пакет IdentityModel, чтобы сформировать верный запрос к token endpoint.

private async Task<string> RefreshAccessTokenAsync(string refreshToken)
{
    var httpContext = httpContextAccessor.HttpContext;

    // NOTE: получение конфигурации сервера авторизации
    // NOTE: если исходный запрос будет отменен, то использование RequestAborted отменит запрос конфигурации
    var oidcConfiguration = await oidcConfigurationManager.GetConfigurationAsync(httpContext.RequestAborted);

    // NOTE: запрос токенов с помощью IdentityModel
    var tokenResponse = await new HttpClient().RequestRefreshTokenAsync(new RefreshTokenRequest
    {
        Address = oidcConfiguration.TokenEndpoint,
        ClientId = "Photos App by OIDC",
        ClientSecret = "secret",
        RefreshToken = refreshToken,
    });

    // NOTE: обновление access token и refresh token в аутентификационной cookie
    // NOTE: Схему можно не указывать, потому что DefaultScheme подходит, DefaultAuthenticateScheme не задана
    var authResult = await httpContext.AuthenticateAsync();
    if (tokenResponse.RefreshToken != null)
        authResult.Properties.UpdateTokenValue(OpenIdConnectParameterNames.RefreshToken, tokenResponse.RefreshToken);
    if (tokenResponse.AccessToken != null)
        authResult.Properties.UpdateTokenValue(OpenIdConnectParameterNames.AccessToken, tokenResponse.AccessToken);
    // NOTE: Схему можно не указывать, потому что DefaultSignInScheme подходит
    await httpContext.SignInAsync(authResult.Principal, authResult.Properties);

    return tokenResponse.AccessToken;
}

Теперь можно повторить эксперимент. Войди под пользователем vicky@gmail.com через Passport. На главной странице должны подгрузиться фотографии. Подожди полминуты и обнови главную страницу. Фотографии должны остаться.

Замечание. После перезапуска IdentityServer теряет все refresh-токены (ведь он не подключен к реальной базе данных), поэтому при перезапуске приложения фотографии отображаться не будут. Просто PhotosApp еще будет помнить refresh-токен, а IdentityServer уже нет.

10.5. Валидация токенов

Вообще-то можно не отправлять заведомо старые access-токены в PhotosService. Можно проверять access-токен перед запросом и, если он старый, то сразу запрашивать новый.

Свою реализацию проверки токена писать, конечно, не нужно, ведь в .NET уже есть готовая.

Добавь в RemotePhotosRepository такой метод проверки:

private async Task<TokenValidationResult> ValidateTokenAsync(string accessToken)
{
    var validationParameters = new TokenValidationParameters
    {
        ValidateIssuer = false,
        ValidateAudience = false,
        ValidateLifetime = true,
        ClockSkew = TimeSpan.Zero,
        // NOTE: Переопределение проверки подписи токена, чтобы подпись не проверялась,
        // ведь ее не получится проверить без закрытого ключа
        SignatureValidator = (token, validationParameters) => new JsonWebToken(token)
    };

    var tokenHandler = new JsonWebTokenHandler();
    var validationResult = tokenHandler.ValidateToken(accessToken, validationParameters);
    return validationResult;
}

В нем TokenValidationParameters настраиваются таким образом, чтобы проверялось только время жизни токена.

Теперь перепиши SendAsync так, чтобы перед первым запросом к PhotosService проверялся токен и сам запрос происходил только в том случае, когда validationResult.IsValid. А иначе сразу доставался refresh token, запрашивался новый access token и запрос к PhotosService происходил уже с новым access token.

Раз уж возник вопрос валидации токенов, то исключительно ради эксперимента, добавь проверку подписи токена в ValidateTokenAsync. Это на самом деле не требуется, т.к. подпись id token проверяется при аутентификации, а подпись access token будет в любом случае проверяться PhotosService. Но уметь проверять в коде токен полезно. Тем более, что уже вручную проверять умеешь.

Итак, в начале метода придется получить конфигурацию сервера авторизации и JWK из нее:

var httpContext = httpContextAccessor.HttpContext;
var oidcConfiguration = await oidcConfigurationManager.GetConfigurationAsync(httpContext.RequestAborted);
var issuerSigningKeys = oidcConfiguration.SigningKeys;

А затем подправить TokenValidationParameters перед проверкой вот так:

// NOTE: если все же хочется проверить подпись, то переопределять не нужно
validationParameters.SignatureValidator = null;
// NOTE: для проверки подписи нужен открытый ключ сервера авторизации
validationParameters.IssuerSigningKeys = issuerSigningKeys;
// NOTE: токены совсем без подписи вообще-то надо всегда отбрасывать — они ничтожны
validationParameters.RequireSignedTokens = true;

Убедись, что даже с такой «усиленной» проверкой приложение до сих пор работает.

10.6. Интроспекция токенов

Если вы хотите использовать возможность отзывать access-токены с помощью сервера авторизации, то при предъявлении токена ресурсу, этот ресурс должен отправить токен на проверку в сервер авторизации. Сервер авторизации в этом случае сверит токен со своим списком отозванных токенов, а затем одобрит или отклонит использование токена.

Еще одна ситуация, когда может понадобится отправлять access-токен на проверку в сервер авторизации — это использование «непрозрачных» access-токенов. Access-токен — это не обязательно JWT, который в доступном виде хранит свое содержимое. Это может быть просто строка, которую выдал и о которой знает сервер авторизации. В этом случае ресурс только с помощью сервера авторизации может получить claims, связанные с токеном.

В обоих случаях используется интроспекция — специальный запрос к серверу авторизации на специальный endpoint.

На C# с использованием библиотеки IdentityModel интроспекция может выглядеть так:

async Task<(bool isActive, Claim[] claims)> IntrospectTokenAsync(string securityToken)
{
    var client = new HttpClient();

    // NOTE: запрашивается конфигурация сервера авторизации, внутри она кэшируется
    var disco = await client.GetDiscoveryDocumentAsync(authorityAddress);

    var response = await client.IntrospectTokenAsync(new TokenIntrospectionRequest
    {
        Address = disco.IntrospectionEndpoint,
        // NOTE: хоть поле называется clientId, но это идентификатор ресурса, а не клиента
        ClientId = apiResourceId,
        // NOTE: для защиты от несанкционированных запросов на проверку токенов используется секрет ресурса
        ClientSecret = apiResourceSecret,

        Token = securityToken
    });

    if (response.IsError)
        throw new TokenIntrospectionException(response.Error);
    
    
    return (
        isActive: response.IsActive, // NOTE: Не прошло ли время действия токена? Не отозван ли он?
        claims: response.Claims.ToArray() // NOTE: Сервер авторизации расшифровывает содержимое токена
    );
}

Используй для проверки access-токенов в PhotosService интроспекцию!

Прежде всего понадобится зарегистрировать секрет, который будет использовать PhotosService для авторизации. Для этого измени описание ресурса в Config.cs в IdentityServer.

new ApiResource("photos_service", "Сервис фотографий")
{
    Scopes = { "photos" },
    ApiSecrets = { new Secret("photos_service_secret".Sha256()) }
},

Затем тебе бы понадобилось написать новый SecurityTokenValidator, в котором токен будет проверяться как локально, так и с помощью интроспекции. Но этот валидатор уже написан. Найди и посмотри его в файле IntrospectionSecurityTokenValidator.cs в PhotosService.

Наконец, новый валидатор токенов надо подключить в Startup.cs в PhotosService. Вот так это можно сделать:

services.AddAuthentication("Bearer")
    .AddJwtBearer("Bearer", options =>
    {
        const string authority = "https://localhost:7001";
        const string apiResourceId = "photos_service";
        const string apiResourceSecret = "photos_service_secret";

        options.Authority = authority;
        options.Audience = apiResourceId;

        options.SecurityTokenValidators.Clear();
        options.SecurityTokenValidators.Add(new IntrospectionSecurityTokenValidator(
            authority, apiResourceId, apiResourceSecret));
        
        ...
    });

10.7. Сохранение токенов

Сейчас все токены, полученные приложением хранятся в http-only cookie. Это достаточно безопасно: современные браузеры гарантируют, что скрипты, в том числе скрипты злоумышленников, не смогут получить доступ к таким cookie, а https может гарантировать, что cookie не будут перехвачены при передаче.

Но можно токены вообще не передавать в браузер. Например, за счет использования TicketStore и сессий. А еще их можно сохранить в базе данных для пользователя и доставать, когда потребуется.

Сохранять токены в базе данных может потребоваться не только из соображений безопасности, но и для того, чтобы выполнять некоторые действия от имени пользователя, когда тот находится «offline»: достаточно просто достать refresh token из БД, получить access token — и можно делать все, что разрешил пользователь.

Короче, полезное это дело уметь сохранять токены в базу данных. Только перед сохранением их надо получить. Для этого добавь в вызове метода AddOpenIdConnect обработку события получения токенов:

options.Events = new OpenIdConnectEvents()
{
    OnTokenResponseReceived = context =>
    {
        var tokenResponse = context.TokenEndpointResponse;
        var tokenHandler = new JwtSecurityTokenHandler();

        SecurityToken accessToken = null;
        if (tokenResponse.AccessToken != null)
        {
            accessToken = tokenHandler.ReadToken(tokenResponse.AccessToken);
        }

        SecurityToken idToken = null;
        if (tokenResponse.IdToken != null)
        {
            idToken = tokenHandler.ReadToken(tokenResponse.IdToken);
        }

        string refreshToken = null;
        if (tokenResponse.RefreshToken != null)
        {
            // NOTE: Это не JWT-токен
            refreshToken = tokenResponse.RefreshToken;
        }

        return Task.CompletedTask;
    },
    ...
};

Добавь и с помощью отладки проверь, что токены действительно приходят. А сохранить их в нужную тебе базу данных — дело нехитрое.

10.R. Резюме

Ты познакомился с Authorization Code Flow, а значит научился авторизовать пользователя в стороннем сервисе, вместо того, чтобы авторизовать приложение в это сервисе, как это было в Client Credentials Flow.

И тут оказалось много нюансов:

  • access-токен может быть короткоживущим или долгоживущим;
  • access-токен можно проверять по подписи, а можно через интроспекцию
  • когда действие access-токена истекает его можно обновлять с помощью refresh-токена или через Silent Renew

В результате получилась полноценная аутентификация и авторизация с использованием сервера авторизации по протоколу OpenID Connect, когда токены хранятся в cookie.

11. Использование OpenID Connect на стороне клиента

В предыдущих заданиях управление токенами происходило на стороне сервера:

  • на стороне сервера инициировался переход в сервер авторизации для входа пользователя
  • сервер обрабатывал обратный вызов /signin-passport после успешного логина, в котором происходил обмен authorization code на токены
  • полученные от сервера авторизации токены сохранялись в http-only cookie, которая была недоступна клиентскому JavaScript
  • сервер получал новые access-токены по refresh-токену

Это была полностью рабочая схема, которую можно использовать в веб-приложениях, в том числе в Single Page Applications. Минус только в том, что если хочется использовать access-токены, то нужно получать refresh-токен. А это может быть нежелательно, ведь refresh-токен предполагает доступ в данным пользователя дажет тогда, когда он неактивен.

Альтернативное решение — это управление токенами на стороне клиента. В этом случае id-токены и access-токены сохраняются в LocalStorage браузера, а не в cookie, а обновлять их можно без использования refresh-токена с помощью подхода Silent Renew.

Вот этот альтернативный подход и надо опробовать на примере приложения PhotosSPA.

11.1. Аутентификация

Для начала научись запускать приложение PhotosSPA. Это можно сделать под откладкой или с помощью файлов папки launch. Приложение должно успешно запуститься по адресу https://localhost:8001 и поприветствовать тебя на главной странице. Другие страницы пока не должны работать правильно.

Загляни в файл Startup.cs в PhotosSPA и обрати внимание, что делает в этом приложении серверная часть. Примерно ничего. Она просто отдает статику: статику из wwwroot и статику SPA-приложения из ClientApp. Еще в ASP.NET Core есть обработчик для показа страницы в случае ошибки на бэкенде, но на этом все: никаких контроллеров, никаких razor-страниц.

Замечание. Есть нюанс, что в среде Development для SPA на самом деле запускается webpack dev server, который на лету строит и отдает клиенсткие скрипты, но сам ASP.NET Core от этого больше не делает.

Начать стоит с добавления информации о новом приложении в сервер авторзации. Для этого в файл Config.cs добавь следующую конфигурацию:

new Client
{
    ClientId = "Photos SPA",
    // NOTE: SPA не может хранить секрет, потому что полные исходники доступны в браузере
    RequireClientSecret = false,
    // NOTE: Поэтому для безопасного получения токена необходимо использовать Proof Key for Code Exchange
    // Эта опция включена по умолчанию, но здесь пусть будет включена явно
    RequirePkce = true,

    AllowedGrantTypes = GrantTypes.Code,
    
    // NOTE: показывать ли пользователю страницу consent со списком запрошенных разрешений
    RequireConsent = false,

    // NOTE: куда отправлять после логина
    RedirectUris = { "TODO: полный URL куда возвращать после логина на сервере авторизации" },

    // NOTE: куда предлагать перейти после логаута
    PostLogoutRedirectUris = { "TODO: полный URL куда возвращать после выхода на сервере авторизации" },

    // NOTE: откуда могут приходить запросы из JS
    AllowedCorsOrigins = { "TODO: надо зарегистрировать адрес приложения в качестве допустимого origin" },

    AllowedScopes = new List<string>
    {
        // NOTE: Позволяет запрашивать id token
        IdentityServerConstants.StandardScopes.OpenId,
        // NOTE: Позволяет запрашивать профиль пользователя через id token
        IdentityServerConstants.StandardScopes.Profile,
        // NOTE: Позволяет запрашивать email пользователя через id token
        IdentityServerConstants.StandardScopes.Email,
    },

    // NOTE: Надо ли добавлять информацию о пользователе в id token при запросе одновременно
    // id token и access token, как это происходит в code flow.
    // Либо придется ее получать отдельно через user info endpoint.
    AlwaysIncludeUserClaimsInIdToken = true,

    // NOTE: refresh token точно не будет использоваться
    AllowOfflineAccess = false,
}

Аккуратно прочти комментарии в этой конфигурации. Обрати внимание на настройки RequireClientSecret = false и AllowOfflineAccess = false. Также заметь, что конфигурация заполнена не полностью — ты ее дозаполнишь чуть позже самостоятельно.

Теперь сервер авторизации знает о приложении PhotosSPA.

Следующий шаг — сделать так, чтобы приложение PhotosSPA узнало о сервере авторизации.

Взаимодействие по протоколу OIDC между приложением и сервером авторизации будет осуществляться через библиотеку oidc-client.js. Чтобы ей было удобнее пользоватся в приложении PhotosSPA над ней написана обертка в файле src/components/api-authorization/AuthorizeService.js. А настройки аутентификации и авторизации вынесены в файл src/components/api-authorization/ApiAuthorizationConstants.js.

Чтобы приложение узнало о сервере авторизации заполни в ApiAuthorizationConstants.js все значения в объекте ApiAuthorizationClientConfiguration, помеченные TODO. Для этого воспользуйся конфигурацией из Config.cs. Эту конфигурацию придется дозаполнить.

Подсказки:

  • Раз в сервере авторизации AllowedGrantTypes = GrantTypes.Code, то подразумевается response type code.
  • redirect_uri и post_logout_redirect_uri можно придумать любые, но эти URL должны соответствовать ApplicationPaths.LoginCallback и ApplicationPaths.LogOutCallback из ApiAuthorizationConstants.js, а также конфигурации из Config.cs.

Когда ты заполнишь ApiAuthorizationClientConfiguration можно попробовать запустить PhotosSPA и IdentityServer, и в приложении PhotosSPA нажать на кнопку Login. К сожалению, даже при корректной конфигурации будет ошибка. Все из-за CORS.

Так как сейчас все запросы к IdentityServer идут напрямую из браузера, а не из серверной части веб-приложения, надо конфигурировать CORS. Благо сделать это в IdentityServer очень просто. Надо всего лишь задать настройку AllowedCorsOrigins в конфигурации клиента в Config.cs. Сделай это. При этом помни, что origin включает схему, хост и порт, но не включает путь. https://google.com — это origin. https://google.com/ — нет.

Убедись, что сейчас вход в приложении PhotosSPA работает: при нажатии на Login происходит переход в сервер авторизации, там можно зайти, например, под cristina, после чего произойдет переход в приложение, а в нем в правом верхнем углу появится email пользователя.

После входа убедись, что id-токен и access-токен от IdentityServer находятся в LocalStorage браузера. В Chrome это можно сделать так: открой «Developer Tools», в них вкладку «Application», а на ней секцию «Storage» и пункт «Local Storage». Там найди запись, начинающуюся с «Photos SPAuser». Внутри тебя должен ждать JSON такого вида:

access_token: "..."
expires_at: 1619445822
id_token: "..."
profile: ...
scope: "openid profile email"
session_state: "..."
token_type: "Bearer"

Также убедись, что и Logout работает, причем после выхода в сервере авторизации происходит переход обратно в приложение.

Последнее, что надо сделать — это задать настройки RegisterRedirectUrl и ProfileRedirectUrl в ApiAuthorizationConstants.js. На нашем сервере авторизации нет специализированных страниц регистрации и профиля, поэтому пусть RegisterRedirectUrl ведет на https://localhost:7001, а ProfileRedirectUrl ведет на https://localhost:7001/diagnostics.

11.2. Авторизация

Пришло время научиться получать фотографии. В этот раз напрямую из PhotosService. А все потому, что access-токен уже находится в браузере и его не надо доставать из cookie.

Чтобы access-токен, который получает oidc-client.js, признавался PhotosService надо разрешить серверу авторизации отдавать скоуп photos, а приложению этот скоуп запрашивать. Первое можно сделать в Config.cs, второе — в ApiAuthorizationConstants.js.

Еще один нюанс, связанный с тем, что теперь все работает в браузере — это CORS. Надо разрешить PhotosService принимать запросы от https://localhost:8001.

Для этого в Startup.cs в PhotosService добавь следующий код в метод ConfigureServices:

services.AddCors(options =>
{
    options.AddDefaultPolicy(
        builder =>
        {
            builder.WithOrigins("https://localhost:8001")
                .AllowAnyHeader()
                .AllowAnyMethod();
        });
});

А в том же файле в методе Configure добавь между app.UseRouting(); и app.UseAuthentication(); такой вызов:

app.UseCors();

Порядок подключений middleware здесь имеет значение!

Теперь запусти PhotosSPA, IdentityService и PhotosService, а затем перейди на страницу «Все фотографии» в приложении. Должен появиться список названий фотографий (пока еще без самих фотографий).

А все потому, что в Photos.js в функции populatePhotos происходит запрос к PhotosService с access-токеном, а PhotosService готов отдать информацию о фотографиях.

Хоть целью не является изучение разработки клиентской части веб-приложений, можешь прочесть комментарии в файле Photos.js и примерно понять как в нем все устроено.

11.3. Silent Renew

Access-токен имеет ограниченное время действия. А значит через некоторое время после входа в приложение PhotosApp список фотографий перестанет показываться.

Сейчас у access-токен выставляется некоторое время жизни по умолчанию и оно достаточно большое. Сделай его поменьше, задав в Config.cs в клиенте "Photos SPA" опцию

AccessTokenLifetime = 1*60,

Запусти PhotosSPA, IdentityService и PhotosService, выйди из текущего аккаунта, если был вход, зайди заново, перейди на страницу «Все фотографии», чтобы убедиться, что они на месте. Подожди минуту, перейди на главную страницу, кликнув на логотип, а затем вернись на «Все фотографии». Убедись, что возникает ошибка доступа.

А теперь, когда проблема лежит на поверхности, можно ее исправить. Для этого добавь в ApiAuthorizationClientConfiguration в файле ApiAuthorizationConstants.js такие настройки:

automaticSilentRenew: true, // Пытаться ли автоматически обновлять access token перед истечением срока его действия
accessTokenExpiringNotificationTime: 30, // За сколько секунд до истечения срока действия access token делать попытку его обновления
includeIdTokenInSilentRenew: true, // Включать ли id_token в качестве id_token_hint при вызовах silent renew

Самая важная настройка здесь, конечно, automaticSilentRenew. Она разрешает обновлять access-токен в фоновом режиме. Токен лучше обновлять заранее, еще до того, как его время действия истечет. Насколько заранее определяет настройка accessTokenExpiringNotificationTime.

Хорошо, когда время в accessTokenExpiringNotificationTime больше, чем AccessTokenLifetime, иначе запросы на новый access-токен будут происходить один за другим. Сейчас это верно, но чтобы четко проверить работу Silent Renew увеличь время жизни access-токена в Config.cs:

AccessTokenLifetime = 2*60,

Теперь снова запусти приложение и сервисы, выйди, войди. Подожди 90 секунд и убедись по логам в консоли IdentityServer, что кто-то постучался за новыми токенами. А после того, как пройдет больше 120 секунд убедись, что можно перейти на главную страницу, а затем на страницу «Все фотографии» и список фотографий будет подгружен.

На самом деле автоматический Silent Renew не решает всех проблем, т.к. возможна ситуация, когда пользователь закроет вкладку и автоматический Silent Renew будет пропущен. При следующем заходе на вкладку аутентификация формально сохранится, а вот время действия access-токена уже истечет.

Эту ситуацию можно обработать по-разному. И поведение должно зависеть от логики приложения и ожиданий пользователя.

Один из возможных вариантов — подписаться на уведомление UserManager из oidc-client.js о том, что access-токен истек: userManager.events.addAccessTokenExpired. И признавать пользователя «неаутентифицированным» при срабатывании этого события. В результате при открытии страницы, где требуется аутентификация, например, «Все фотографии», будет происходить переадресация на страницу логина в IdentityServer. Как будно пользователь еще не входил. Но если в IdentityServer сессия сохранилась, то тут же последует обратный переход, а пользователь без лишних действий снова увидет свои данные.

Добиться такого поведения можно следующими изменениями в файле AuthorizeService.js.

Во-первых, надо обновить метод getUser:

async getUser() {
if (this._user && this._user.profile) {
    return this._user.profile;
}

await this.ensureUserManagerInitialized();
const user = await this.userManager.getUser();
const accessTokenExpired = !user || typeof user.expired !== "boolean" || user.expired;
return user && !accessTokenExpired ? user.profile : null;
}

Во-вторых, надо добавить в методе ensureUserManagerInitialized подписку на addAccessTokenExpired:

this.userManager.events.addAccessTokenExpired(async () => {
    if (this._user !== undefined) {
    this.updateState(undefined);
    }
});

Для проверки работоспособности решения надо выполнить вход, перейти на страницу «Все фотографии», сохранить ее адрес в буфер обмена, закрыть вкладку со страницей, не закрывая браузера, подождать 2 минуты до истечения срока действия access-токена, вновь перейти на страницу «Все фотографии» с помощью сохраненного адреса. В результате произойдет несколько переходов без действий со стороны пользователя, а затем список фотографий отобразится.

11.4. Получение контента с помощью Signed URL

Фотографии пользователей надо показывать. Только как сделать это правильно?

Вопрос нетривиальный, т.к. браузеру удобно, когда картинка — это тег с некоторым адресом . Браузер готов самостоятельно получить GET-запросом картинку и закэшировать, но он не будет отправлять access-токен в HTTP-заголовке Authorization. Он отправит только cookies. А в текущей схеме в cookies нет access-токена.

Так как же передавать access-токен для получения картинки из PhotosService?

Возможные идеи:

  1. Передавать access-токен через URL, например, через query string.
  2. Создавать не http-only cookie с access-токеном на стороне клиента.
  3. Создавать http-only cookie с access-токеном с помощью бэкенда.
  4. Получать картинки с помощью бэкенда, который будет делать запрос к PhotosService с access-токеном в заголовке, как это происходит в PhotosApp.

Но у всех идей есть недостатки:

  1. Идея 1 просто небезопасна, ведь access-токен в этом случае осядет в логах всех промежуточных маршрутизаторов, по которым будет проходить запрос от браузера до PhotosService. Ведь URL доступен и логируется, в отличие от заголовков и тела запроса.
  2. Идея 2 небезопасна, если приложение не защищено от атаки XSS.
  3. Идеи 3 заставляет использовать бэкенд при каждом обновлении access-токена.
  4. Идея 4 заставляет вернуть сложный бэкенд, а вроде и без него было все хорошо в SPA-приложении.

Придется предложить еще одну нетривиальную идею и все-таки ее реализовать. Эта идея — Signed URL.

Суть идеи — прилагать к URL контента некоторую подпись в качестве параметра query string, которая будет фиксировать остальные параметры запроса плюс содержать внутри время действия этого URL. Подпись будет формироваться с помощью закрытого ключа, а расшифровать и убедиться в ее подлинности сможет любой с помощью открытого ключа.

Signed URL очень похож на идею 1, т.к. подпись — это почти тот же токен. Разница в том, что Signed URL может действовать занчительно меньше access-токена, а еще дает доступ только к одному элементу контента и только на чтение.

Замечание. Здесь не утверждается, что это лучшая идея для получения контента со сторонних сервисов в SPA-приложении.

Если методы для формирования Signed URL, а также их проверки уже написаны, то подключить это технологию просто. И эти методы уже реализованы в классе SignedUrlHelpers в PhotosService. Можешь посмотреть, как там все устроено, а можешь сразу перейти к использованию. Нужные методы — это SignedUrlHelpers.CreateSignedUrl и SignedUrlHelpers.CheckSignedUrl.

Также облегчает задачу то, что PhotosService уже умеет возвращать URL для получения картинок при запросе информации о единичной фотографии или списка фотографий. И в списке фотографий даже видно этот URL. Только по нему ничего не удастся получить без access-токена.

Поменяй в Photos.js из PhotosSPA код:

<span>{photo.url}</span>

на следующий:

<img src={photo.url} className="photo" />

И убедись, что фотографии не подружаются.

Добавь в PhotosApiController из PhotosService метод, который будет обрабатывать Signed URL для фотографий и отдавать их содержимое:

[AllowAnonymous]
[HttpGet("{id}/signed-content")]
public async Task<IActionResult> GetPhotoSignedContent(Guid id)
{
    var currentUrl = HttpContext.Request.GetEncodedUrl();
    var check = SignedUrlHelpers.CheckSignedUrl(currentUrl);
    if (!check)
        return Forbid();
    
    var photoEntity = await photosRepository.GetPhotoMetaAsync(id);
    if (photoEntity == null)
        return NotFound();

    var photoContent = await photosRepository.GetPhotoContentAsync(id);
    if (photoContent == null)
        return NotFound();

    return File(photoContent.Content, photoContent.ContentType, photoContent.FileName);
}

Он во многом похож на GetPhotoContent, только контроль доступа реализован иначе. Конечно, метод GetPhotoSignedContent должен быть помечен атрибутом AllowAnonymous, т.к. не использует стандартные механизмы авторизации.

Наконец, замени в PhotosApiController из PhotosService логику построения URL для фотографии, чтобы URL указывал на GetPhotoSignedContent и подписывался:

private string GeneratePhotoUrl(PhotoDto photo)
{
    var relativeUrl = Url.Action(nameof(GetPhotoSignedContent), new {
        id = photo.Id
    });
    var url = "https://localhost:6001" + relativeUrl;

    var nowUtc = DateTime.UtcNow;
    var signedUrl = SignedUrlHelpers.CreateSignedUrl(url, nowUtc, nowUtc.AddMinutes(5));
    return signedUrl;
}

Убедись, что теперь фотографии отображаются в PhotosSPA.

11.R. Резюме

Теперь у тебя получилась полноценная аутентификация и авторизация с использование сервера авторизации по протоколу OpenID Connect, когда токены хранятся в LocalStorage браузера.

Такой вариант подходит для Single Page Application. Причем фронтенд веб-приложения может обращаться к различным API напрямую, используя хранящиеся в браузере токены. Правда это требует аккуратной настройки CORS.

Обновление токенов в этом случае происходит через Silent Renew в браузере, а защиту контента можно организовать с помощью Signed URL. При

12. Подключение СУБД к IdentityServer

Сейчас IdentityServer хранит все данные о пользователя в оперативной памяти. В реальности же данные о пользователях должны храниться в базе данных.

Один из способов подключение базы данных к IdentityServer — это использовать Identity.

Тебе уже известно, что Identity включает в себя UserManager для управления пользователями и SignInManager для управления сеансами пользователей. При этом для хранения данных использует интеграцию с EntityFramework Core, а значит может хранить данные в реляционных СУБД.

Таким образом, если подключить Identity к IdentityServer и использовать UserManager и SignInManager, то задача подключения базы данных будет выполнена.

А вот реализацию UI из Identity использовать совершенно не обязательно. Так что UI останется прежним. Только внутри надо будет использовать UserManager и SignInManager.

12.1. Identity с EntityFramework Core

Чтобы добавить Identity с EntityFramework Core для начала надо добавить в проект много сборок. Для этого выполни в папке проектом IdentityServer следущие команды:

dotnet add package IdentityServer4.AspNetIdentity
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet add package Microsoft.EntityFrameworkCore.Tools

Здесь добавляется сборка для Sqlite, но могла бы быть и другая реляционная СУБД.

Далее нужно определить и подключить контекст для EntityFramework Core в проекте IdentityServer.

Создай файл Models/ApplicationUser.cs со следующим содержимым:

using Microsoft.AspNetCore.Identity;

namespace IdentityServer.Models
{
    public class ApplicationUser : IdentityUser
    {
    }
}

Создай файл Data/ApplicationDbContext.cs со следующим содержимым:

using IdentityServer.Models;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace IdentityServer.Data
{
    public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
    {
        public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
            : base(options)
        {
        }
    }
}

В методе ConfigureServices в Startup.cs подключи контекст:

services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlite("DataSource=app.db;Cache=Shared"));

Теперь можно создать миграцию для созданного контекста ApplicationDbContext командой в папке с проектом IdentityServer:

dotnet ef migrations add Users --context ApplicationDbContext

Понадобятся начальные данные, поэтому добавь файл Data/IdentityServerDataExtensions.cs со следующим содержимым:

using IdentityServer.Models;
using System;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace IdentityServer.Data
{
    public static class IdentityServerDataExtensions
    {
        public static void PrepareData(this IHost host)
        {
            using (var scope = host.Services.CreateScope())
            {
                try
                {
                    var env = scope.ServiceProvider.GetRequiredService<IWebHostEnvironment>();
                    if (env.IsDevelopment())
                    {
                        scope.ServiceProvider.GetRequiredService<ApplicationDbContext>().Database.Migrate();

                        var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
                        userManager.SeedWithSampleUsersAsync().Wait();
                    }
                }
                catch (Exception e)
                {
                    var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
                    logger.LogError(e, "An error occurred while migrating or seeding the database.");
                }
            }
        }

        private static async Task SeedWithSampleUsersAsync(this UserManager<ApplicationUser> userManager)
        {
            // NOTE: ToList важен, так как при удалении пользователя меняется список пользователей
            foreach (var user in userManager.Users.ToList())
                await userManager.DeleteAsync(user);

            {
                var user = new ApplicationUser
                {
                    Id = "a83b72ed-3f99-44b5-aa32-f9d03e7eb1fd",
                    UserName = "vicky@gmail.com",
                    Email = "vicky@gmail.com"
                };
                await userManager.RegisterUserIfNotExists(user, "Pass!2");
                await userManager.AddClaimAsync(user, new Claim("testing", "beta"));
            }

            {
                var user = new ApplicationUser
                {
                    Id = "dcaec9ce-91c9-4105-8d4d-eee3365acd82",
                    UserName = "cristina@gmail.com",
                    Email = "cristina@gmail.com",
                };
                await userManager.RegisterUserIfNotExists(user, "Pass!2");
                await userManager.AddClaimAsync(user, new Claim("subscription", "paid"));
            }

            {
                var user = new ApplicationUser
                {
                    Id = "b9991f69-b4c1-477d-9432-2f7cf6099e02",
                    UserName = "dev@gmail.com",
                    Email = "dev@gmail.com"
                };
                await userManager.RegisterUserIfNotExists(user, "Pass!2");
                await userManager.AddClaimAsync(user, new Claim("subscription", "paid"));
                await userManager.AddClaimAsync(user, new Claim("role", "Dev"));
            }
        }

        private static async Task RegisterUserIfNotExists<TUser>(this UserManager<TUser> userManager,
            TUser user, string password)
            where TUser : IdentityUser<string>
        {
            if (await userManager.FindByNameAsync(user.UserName) == null)
            {
                var result = await userManager.CreateAsync(user, password);
                if (result.Succeeded)
                {
                    var code = await userManager.GenerateEmailConfirmationTokenAsync(user);
                    await userManager.ConfirmEmailAsync(user, code);
                }
            }
        }
    }
}

И подключи заполнение начальных данных, добавив следующий строки после var host = hostBuilder.Build(); в Program.cs в IdentityServer:

Log.Information("Preparing data");
host.PrepareData();

Данные готовы. Осталось подключить Identity, причем без UI. Так что надо использовать метод AddIdentity, а не AddDefaultIdentity.

Для этого в методе ConfigureServices в Startup.cs перед цепочкой services.AddIdentityServer добавь строчки:

services.AddIdentity<ApplicationUser, IdentityRole>()
    .AddEntityFrameworkStores<ApplicationDbContext>()
    .AddDefaultTokenProviders();

И там же вместо строчки

.AddTestUsers(TestUsers.Users);

напиши

.AddAspNetIdentity<ApplicationUser>();

Финальный штрих: UI в IdentityServer теперь должен использовать UserManager и SignInManager, поэтому обнови файлы Quickstart/Account/AccountController.cs и Quickstart/Account/ExternalController.cs по заготовкам из папки $Drafts в проекте IdentityServer.

Убедись, что IdentityServer работает как и раньше. Только вот данные теперь он хранит в Sqlite.

12.2. Identity с MongoDB

В качестве альтернативы EntityFramework Core с реляционными СУБД, можно использовать другие реализации Identity, которые используют другие СУБД. Например, библиотеку AspNetCore.Identity.Mongo, использующую MongoDB в качестве СУБД.

Перед тем как использовать Identity с MongoDB придется зачистить ненужное от Identity с EntityFramework Core в проекте IdentityServer:

  1. Удали Data/ApplicationDbContext.cs
  2. Удали папку Migrations
  3. В Data/IdentityServerDataExtensions удали вызов миграции для ApplicationDbContext, т.е. строчку scope.ServiceProvider.GetRequiredService<ApplicationDbContext>().Database.Migrate();
  4. Удали вызовы цепочки вызовов services.AddDbContext и services.AddIdentity из метода ConfigureServices в файле Startup.cs

Теперь можно заняться Identity с MongoDB.

Для начала подключи пакет AspNetCore.Identity.Mongo к проекту IdentityServer:

dotnet add package AspNetCore.Identity.Mongo

Создай файл Models/ApplicationRole.cs со следующим содержимым:

using AspNetCore.Identity.Mongo.Model;

namespace IdentityServer.Models
{
    public class ApplicationRole : MongoRole<string>
    {
    }
}

А файл Models/ApplicationUser.cs обнови так:

using AspNetCore.Identity.Mongo.Model;

namespace IdentityServer.Models
{
    public class ApplicationUser : MongoUser<string>
    {
    }
}

И теперь можно подключить Identity с MongoDB. Для этого в методе ConfigureServices в файле Startup.cs добавь:

services.AddIdentityMongoDbProvider<ApplicationUser, ApplicationRole, string>(
identity =>
{
    // NOTE: просто пример настройки
    identity.Password.RequiredLength = 4;
},
mongo =>
{
    // NOTE: нужная строка подключения для твоего кластера
    // Здесь используется адрес локального кластера по умолчанию
    mongo.ConnectionString = "mongodb://127.0.0.1:27017/identity";
})
.AddDefaultTokenProviders();

Создание пользователей по умолчанию и UI в случае с MongoDB ничем не отличается от случая с EntityFramework Core, поэтому в соответствующем коде менять ничего не пришлось.

Осталось только скачать и установить локальную MongoDB и можно проверять работоспособность IdentityServer. Ссылка, чтобы скачать с официального сайта: https://www.mongodb.com/try/download/community.

12.R. Резюме

Ты научился хранить данные о пользователях сервера авторизации в СУБД за счет подключения Identity.

На этом работа над сервером авторизации не закончена. Его можно улучшать:

  • Расширить UI на основе UI из Identity
  • Более полно настроить Identity
  • Реализовать регистрацию пользователей с подтверждением email
  • Подключить внешних провайдеров, например, Google

В общем, можно применить знания, полученные при настройке Identity в качестве встроенной в приложение аутентификации и авторизации.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published