Практическая реализация современной аутентификации на платформе .NET: OpenID Connect, шаблон BFF и SPA

в 11:15, , рубрики: .net, architecture, authentication, BFF, C#, oauth2, OIDC, React, SPA

Введение

В последние годы протоколы аутентификации OAuth 2.0 и OpenID Connect значительно изменились. Вслед за развитием интернет-технологий и эволюцией угроз, некоторые методы аутентификации, изначально разработанные для веб-приложений, постепенно устаревают. В этой статье мы рассмотрим архитектурный шаблон Backend-For-Frontend (BFF), который в настоящее время рекомендуется для организации взаимодействия одностраничного приложения (Single Page Application, SPA) с бекендом и распределения обязанностей между ними. Мы также создадим минимальное решение, реализующее шаблон BFF с использованием .NET и SPA на React, чтобы проиллюстрировать предлагаемый подход на практике.

Исторический контекст

История OAuth 2.0 и OpenID Connect отражает годы эволюции интернет-технологий. Давайте немного подробнее рассмотрим эти протоколы и их влияние на современные веб-приложения.

Протокол OAuth 2.0 был представлен в 2012 году и стал важным протоколом для авторизации. Он позволяет сторонним приложениям получать ограниченный доступ к ресурсам пользователя без необходимости передавать учетные данные пользователя через клиента. OAuth 2.0 реализует несколько разных потоков (flows), что было задумано как возможность гибко адаптировать его под различные сценарии использования.

Протокол OpenID Connect (OIDC) появился в 2014 году как расширение поверх OAuth 2.0, добавив к нему функциональность аутентификации. Он предоставляет клиентским приложениям стандартный способ удостоверять личность пользователя и получать основную информацию о нем как через стандартизованную точку доступа, так и посредством получения ID-токена в формате JWT (Json Web Token).

Эволюция модели угроз

Вслед за ростом возможностей и популярности SPA эволюционировала и модель угроз. Такие уязвимости, как межсайтовый скриптинг (XSS) и межсайтовая подделка запросов (CSRF), стали более распространенными. Поскольку SPA чаще всего взаимодействуют с сервером через API, безопасное хранение и использование токенов доступа (access tokens) к API и токенов обновления (refresh tokens) стали критически важны для обеспечения безопасности.

Отвечая требованиям времени, протоколы OAuth 2.0 и OpenID Connect продолжают эволюционировать, чтобы адаптироваться к новым вызовам, которые возникают в связи с появлением новых технологий и ростом количества угроз. В то же время постоянная эволюция угроз и совершенствование практик безопасности приводят к тому, что старые подходы перестают отвечать современным требованиям безопасности. В результате протоколы OAuth 2.0 и OpenID Connect в настоящее время представляют широкий спектр возможностей, но многие из них уже стали или в ближайшее время станут считаться устаревшими, а зачастую и вовсе небезопасными. Это многообразие создает трудности для разработчиков SPA приложений, ведь выбрать наиболее правильный и безопасный способ взаимодействия с сервером OAuth 2.0 / OpenID Connect становится непростой задачей.

В частности, уже сейчас Implicit Flow фактически можно считать устаревшим, а для клиентов любого типа, будь то SPA, приложения для мобильных устройств или настольных ПК, сегодня настоятельно рекомендуется использовать Authorization Code Flow вместе с Proof Key for Code Exchange (PKCE). На эту тему есть подробная статья, которая описывает путь эволюции подходов к аутентификации, сопутствующие им риски и способы их устранения.

Безопасность современных SPA

Почему же, даже при использовании Authorization Code Flow совместно с PKCE, в современном мире SPA все еще считаются уязвимыми? На этот вопрос есть сразу несколько ответов.

Уязвимости кода на JavaScript

JavaScript является мощным языком программирования, который играет ключевую роль в современных одностраничных приложениях (SPA). Однако именно широкие возможности и распространенность делают JavaScript потенциальной угрозой. Современные SPA, построенные на таких библиотеках и фреймворках, как React, Vue или Angular, внутри используют огромное количество библиотек и зависимостей. Вы можете увидеть их в папке node_modules, и количество таких зависимостей может исчисляться сотнями или даже тысячами. Каждая из этих библиотек может содержать уязвимости разной степени критичности, а разработчики SPA не имеют возможности для тщательной проверки кода всех использованных зависимостей. Зачастую разработчики и вовсе не отслеживают полного списка зависимостей, поскольку те транзитивно зависят друг от друга. Даже разрабатывая собственный код по высочайшим стандартам качества и безопасности, нельзя быть полностью уверенным в отсутствии уязвимостей в готовом приложении.

Вредоносный JavaScript-код, который может быть внедрен в приложение различными способами, через атаки типа межсайтового скриптинга (XSS) или через компрометацию сторонних библиотек, получает такие же привилегии и уровень доступа к данным, как и легитимный код приложения. Это позволяет вредоносному коду красть данные из текущей страницы, взаимодействовать с интерфейсом приложения, отправлять запросы к backend, красть данные из локального хранилища (localStorage, IndexedDB), и даже самостоятельно инициировать сеансы аутентификации, получая с помощью того же потока Authorization Code и PKCE собственные токены доступа.

Уязвимости типа Spectre

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

Во-первых, SPA интенсивно используют JavaScript для управления состоянием приложения и взаимодействия с сервером. Это увеличивает поверхность атаки для вредоносного JavaScript-кода, который может использовать уязвимости Spectre. Во-вторых, в отличие от традиционных многостраничных приложений (MPA), SPA редко перезагружаются, что означает длительное время жизни страницы и загруженного в контексте нее кода. Это дает злоумышленникам значительно больше времени для выполнения атак с использованием вредоносного JavaScript-кода.

Уязвимости типа Spectre позволяют злоумышленникам красть токены доступа, хранимые в памяти JavaScript-приложения, что дает возможность получить доступ к защищенным ресурсам, имитируя легитимное приложение. Спекулятивное исполнение может быть использовано и для кражи данных сеансов пользователя, что позволяет злоумышленнику продолжать атаки после закрытия SPA.

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

Что же делать?

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

В ответ на перечисленные угрозы, все больше экспертов склоняются к идее полностью отказаться от хранения токенов в браузере и проектировать приложение так, чтобы токены доступа и обновления получала и обрабатывала только серверная часть приложения, и они никогда не передавались бы на сторону браузера. В контексте SPA с бекендом это может быть достигнуто с помощью архитектурного шаблона Backend-For-Frontend (BFF).

Схема взаимодействия между сервером авторизации (OP), клиентом (RP), реализующим шаблон BFF, и сторонним API (Resource Server), выглядит так:

Authorization Code Flow с BFF

Authorization Code Flow с BFF

Преимущества использования BFF для защиты SPA многочисленны. Токены доступа и обновления хранятся на серверной стороне и никогда не передаются в браузер, что предотвращает их кражу через любые виды уязвимостей. Управление сессиями и токенами доступа осуществляется на сервере, что позволяет лучше контролировать безопасность и выполнять проверку подлинности. Клиентское приложение взаимодействует с сервером через BFF, что упрощает логику приложения и снижает риск выполнения вредоносного кода.

Реализация шаблона Backend-For-Frontend на платформе .NET

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

Согласно документу OAuth 2.0 for Browser-Based Applications, архитектурный шаблон BFF предполагает, что серверная часть приложения (бекенд) выступает в роли клиента OpenID Connect, для аутентификации использует Authorization Code Flow совместно с PKCE, получает и сохраняет на своей стороне токены доступа и обновления, не передавая их на сторону браузера в SPA. Также BFF предполагает наличие API на стороне бекенда, состоящего из четырех основных endpoint-ов:

BFF Endpoints

BFF Endpoints
  1. Check Session: служит для проверки наличия активной аутентификации пользователя. Обычно вызывается со стороны SPA с помощью асинхронного API (fetch) и в случае успеха возвращает информацию об активном пользователе. Таким образом, SPA, загруженное из третьего источника (например CDN), имеет возможность проверить статус аутентификации и либо продолжить свою работу с данным пользователем, либо перейти к аутентификации с помощью сервера OpenID Connect.

  2. Login: выполняет переход к аутентификации на сервер OpenID Connect. Обычно SPA в случае, когда на шаге 1 через Check Session не удалось получить данные об аутентифицированном пользователе, выполняет перенаправление браузера (Redirect) на этот URL, а тот в свою очередь формирует полный запрос к серверу OpenID Connect и перенаправляет браузер туда.

  3. Sign In: получает Authorization Code, переданный сервером после выполнения шага 2 в результате успешной аутентификации. Выполняет прямой запрос к серверу OpenID Connect для обмена Authorization Code + PKCE code verifier на Access и Refresh токены. Инициирует аутентифицированную сессию на стороне клиента, выписывая пользователю аутентификационную cookie.

  4. Logout: служит для завершения сессии аутентификации. Обычно SPA перенаправляет браузер на этот URL, где в свою очередь формируется запрос к End Session endpoint на сервере OpenID Connect для завершения сессии, а также удаляется сессия на стороне клиента и аутентификационная cookie.

Теперь попробуем подобрать инструменты, которые уже предоставляет нам платформа .NET "из коробки" и которые могут быть полезны для реализации шаблона BFF. Платформа .NET уже располагает nuget-пакетом Microsoft.AspNetCore.Authentication.OpenIdConnect, который представляет из себя готовую реализацию клиента OpenID Connect, поддерживаемую компанией Microsoft. В этот пакете поддерживается как Authorization Code Flow, так и PKCE, а также он добавляет приложение endpoint с относительным путем /signin-oidc, в которой уже реализована необходимая нам функциональность Signin Endpoint (см. пункт 3 выше). Таким образом, нам остается реализовать только три оставшихся endpoint-а.

Для практического примера интеграции мы воспользуемся тестовым сервером OpenID Connect, созданным на базе библиотеки Abblix OIDC Server. Однако всё, что будет сказано ниже, применимо и к любому другому серверу, включая как публично доступные сервера Facebook, Google, Apple, так и любые другие, совместимые со спецификацией протокола OpenID Connect.

Для реализации SPA в браузере мы воспользуемся библиотекой React, а на стороне сервера у нас будет использован .NET WebAPI. Это один из наиболее распространенных вариантов технологического стека на момент написания этой статьи.

Общая схема компонентов и их взаимодействия выглядит следующим образом:

Компоненты и их взаимодействие

Компоненты и их взаимодействие

Для работы примеров из этой статьи вам надо будет также установить себе .NET SDK и Node.js. Все примеры из этой статьи были разработаны на версиях .NET 8, Node.js 22 и React 18, актуальных на момент ее написания.

Создание клиентского SPA на React с бекендом на .NET

Для быстрого создания приложения клиента удобнее воспользоваться готовым шаблоном. До версии .NET 7 вместе с SDK поставлялся встроенный шаблон приложения на .NET WebAPI и SPA на React. К сожалению, из актуальной на момент написания этой статьи версии .NET 8 этот шаблон был исключен. Поэтому команда Abblix создала свой собственный шаблон, содержащий бекенд на .NET WebApi, фронтенд на базе библиотеки React и языке TypeScript, собираемый с помощью Vite. Этот шаблон доступен публично в составе пакета Abblix.Templates и вы можете установить его, выполнив команду:

dotnet new install Abblix.Templates

Теперь нам стал доступен шаблон с именем abblix-react. Воспользуемся им для создания нового приложения BffSample:

dotnet new abblix-react -n BffSample

Этой командой мы создали приложение, состоящее из серверной части на .NET WebApi и клиентского SPA на React. Файлы, относящиеся к SPA, находятся в папке BffSampleClientApp.

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

cmd /c "cd ClientApp && npm install"

Это действие необходимо для того, чтобы установить все необходимые npm-пакеты для клиентской части приложения. Для успешного запуска проекта рекомендуется согласиться и выполнить эту команду, введя Y (yes).

Сразу заменим в приложении BffSample номер порта, на котором оно запускается локально, на 5003. Это действие не является обязательным, но оно упростит нам дальнейшую настройку сервера OpenID Connect. Для этого в файле BffSamplePropertieslaunchSettings.json найдем профиль с именем https и заменим внутри него значение свойства applicationUrl на https://localhost:5003.

Теперь подключим nuget-пакет, реализующий клиента OpenID Connect, перейдя в папку BffSample и выполнив команду:

dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect

Настроим в приложении две схемы аутентификации с именами Cookies и OpenIdConnect, считывая их настройки из конфигурации приложения. Для этого внесем изменения в файл BffSampleProgram.cs:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
// ******************* START *******************
var configuration = builder.Configuration;

builder.Services
    .AddAuthorization()
    .AddAuthentication(options => configuration.Bind("Authentication", options))
    .AddCookie()
    .AddOpenIdConnect(options => configuration.Bind("OpenIdConnect", options));
// ******************** END ********************
var app = builder.Build(); 

И добавим в файл BffSampleappsettings.json нашего приложения необходимые настройки для подключения к серверу OpenID Connect:

{
  // ******************* START *******************
  "Authentication": {
      "DefaultScheme": "Cookies",
      "DefaultChallengeScheme": "OpenIdConnect"
  },
  "OpenIdConnect": {
      "SignInScheme": "Cookies",
      "SignOutScheme": "Cookies",
      "SaveTokens": true,
      "Scope": ["openid", "profile", "email"],
      "MapInboundClaims": false,
      "ResponseType": "code",
      "ResponseMode": "query",
      "UsePkce": true,
      "GetClaimsFromUserInfoEndpoint": true
  },
  // ******************** END ********************
  "Logging": {
    "LogLevel": {
      "Default": "Information",

А в файл BffSampleappsettings.Development.json:

{
  // ******************* START *******************
  "OpenIdConnect": {
      "Authority": "https://localhost:5001",
      "ClientId": "bff_sample",
      "ClientSecret": "secret"
  },
  // ******************** END ********************
  "Logging": {
    "LogLevel": {
      "Default": "Information",

Рассмотрим кратко каждую из настроек и ее назначение:

  • Секция Authentication: свойство DefaultScheme задает аутентификацию по умолчанию с помощью схемы Cookies, а DefaultChallengeScheme делегирует cхеме OpenIdConnect выполнение аутентификации в случае, когда пользователь не может быть аутентифицирован схемой по умолчанию. Таким образом, когда пользователь неизвестен приложению, для аутентификации будет вызван сервер OpenID Connect, а после этого аутентифицированный пользователь получит аутентификационную сookie и все дальнейшие вызовы сервера будут аутентифицированы уже с ее помощью, без обращения к серверу OpenID Connect.

  • Секция OpenIdConnect:

    • свойства SignInScheme и SignOutScheme указывают на схему Cookies, которая будет использована для сохранения информации о входе пользователя.

    • Свойство Authority содержит базовый URL сервера OpenID Connect. ClientId и ClientSecret задают идентификатор и секретный ключ клиентского приложения, которые регистрируются на сервере OpenID Connect.

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

    • Scope содержит список областей, к которым приложение BffClient запрашивает доступ. В данном случае запрашиваются стандартные области openid (идентификатор пользователя), profile (профиль пользователя) и email (электронная почта).

    • MapInboundClaims отвечает за преобразование входящих claim-ов от сервера OpenID Connect в claim-ы, используемые в приложении. Значение false означает, что claim-ы будут сохранены в аутентифицированной сессии пользователя в том виде, в котором они получены от сервера OpenID Connect.

    • ResponseType со значением code говорит о том, что клиент будет использовать Authorization Code Flow.

    • ResponseMode задает передачу Authorization Code в строке запроса (query), что является способом по умолчанию, принятым для Authorization Code Flow.

    • Свойство UsePkce указывает на необходимость использования PKCE при аутентификации для предотвращения перехвата Authorization Code.

    • Свойство GetClaimsFromUserInfoEndpoint говорит о том, что данные о профиле аутентифицированного пользователя следует получать с UserInfo endpoint.

Поскольку наше приложение не предполагает взаимодействия с пользователем без аутентификации, сделаем так, чтобы приложение SPA на React загружалось только после успешной аутентификации. Конечно, если загрузка SPA будет выполняться из стороннего источника Static Web Host, например с серверов Content Delivery Network (CDN) или с локального сервера разработки, запускаемого командой npm start (например, при запуске нашего примера в режиме отладки), то проверить статус аутентификации до загрузки SPA не получится. Но, когда за загрузку SPA отвечает наш собственный бекенд на .NET, это возможно.

Для этого подключим Middleware, ответственные за аутентификацию и авторизацию, в файлe BffSampleProgram.cs:

app.UseRouting();
// ******************* START *******************
app.UseAuthentication();
app.UseAuthorization();
// ******************** END ********************

В конце файла BffSampleProgram.cs, где непосредственно осуществляется переход к загрузке SPA, добавим необходимость авторизации .RequireAuthorization():

app.MapFallbackToFile("index.html").RequireAuthorization();

Настройка сервера OpenID Connect

Как мы уже писали выше, для практического примера интеграции мы воспользуемся тестовым сервером OpenID Connect, созданным на базе библиотеки Abblix OIDC Server. Шаблон готового приложения на базе ASP.NET Core MVC с подключенной библиотекой Abblix OIDC Server также доступен в пакете Abblix.Templates, который мы установили ранее. Воспользуемся этим шаблоном для создания нового приложения OpenIDProviderApp:

dotnet new abblix-oidc-server -n OpenIDProviderApp

Для настройки сервера нам необходимо зарегистрировать приложение BffClient в качестве клиента на сервере OpenID Connect и добавить тестового пользователя. Для этого добавим представленные ниже блоки в файл OpenIDProviderAppProgram.cs:

var userInfoStorage = new TestUserStorage(
    // ******************* START *******************
    new UserInfo(
        Subject: "1234567890",
        Name: "John Doe",
        Email: "john.doe@example.com",
        Password: "Jd!2024$3cur3")
    // ******************** END ********************
);
builder.Services.AddSingleton(userInfoStorage);

// ...

// Register and configure Abblix OIDC Server
builder.Services.AddOidcServices(options =>
{
    // Configure OIDC Server options here:
    // ******************* START *******************
    options.Clients = new[] {
        new ClientInfo("bff_sample") {
            ClientSecrets = new[] {
                new ClientSecret {
                    Sha512Hash = SHA512.HashData(Encoding.ASCII.GetBytes("secret")),
                }
            },
            TokenEndpointAuthMethod = ClientAuthenticationMethods.ClientSecretPost,
            AllowedGrantTypes = new[] { GrantTypes.AuthorizationCode },
            ClientType = ClientType.Confidential,
            OfflineAccessAllowed = true,
            PkceRequired = true,
            RedirectUris = new[] { new Uri("https://localhost:5003/signin-oidc", UriKind.Absolute) },
            PostLogoutRedirectUris = new[] { new Uri("https://localhost:5003/signout-callback-oidc", UriKind.Absolute) },
        }
    };
    // ******************** END ********************
    // The following URL leads to Login action of AuthController
    options.LoginUri = new Uri($"/Auth/Login", UriKind.Relative);

    // The following line generates a new key for token signing. Replace it if you want to use your own keys.
    options.SigningKeys = new[] { JsonWebKeyFactory.CreateRsa(JsonWebKeyUseNames.Sig) };
});

Рассмотрим этот код подробнее: мы регистрируем клиента с идентификатором bff_sample и секретным ключом secret (сохраняя его в виде результата хеширования алгоритмом SHA512), указывая, что для получения токенов будет использован способ аутентификации с передачей секретного ключа в POST-сообщении (ClientAuthenticationMethods.ClientSecretPost). AllowedGrantTypes указывает на то, что для клиента будет разрешено пользоваться только потоком кода авторизации (Authorization Code Flow). ClientType определяет клиента как конфиденциального, т.е. способного безопасно хранить на своей стороне секретный ключ. OfflineAccessAllowed разрешает клиенту использование токенов обновления. PkceRequired указывает на обязательное использование PKCE при аутентификации. RedirectUris и PostLogoutRedirectUris содержат списки допустимых URL для перенаправления после аутентификации и завершения сессии соответственно.

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

Реализация базового BFF API

Выше мы уже упоминали, что использование пакета Microsoft.AspNetCore.Authentication.OpenIdConnect сразу добавляет в наше приложение реализацию Signin endpoint. Теперь наша задача - реализовать оставшуюся часть BFF API. Для этого мы будем реализуем оставшиеся endpoint-ы с помощью контроллера ASP.NET MVC. Начнем с того, что добавим в проект BffSample папку Controllers файл BffController.cs со следующим кодом внутри:

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;

namespace BffSample.Controllers;

[ApiController]
[Route("[controller]")]
public class BffController : Controller
{
    public const string CorsPolicyName = "Bff";

    [HttpGet("check_session")]
    [EnableCors(CorsPolicyName)]
    public ActionResult<IDictionary<string, string>> CheckSession()
    {
        // return 401 Unauthorized to force SPA redirection to Login endpoint
        if (User.Identity?.IsAuthenticated != true)
            return Unauthorized();

        return User.Claims.ToDictionary(claim => claim.Type, claim => claim.Value);
    }

    [HttpGet("login")]
    public ActionResult<IDictionary<string, string>> Login()
    {
        // Logic to initiate the authorization code flow
        return Challenge(new AuthenticationProperties { RedirectUri = Url.Content("~/") });
    }

    [HttpPost("logout")]
    public IActionResult Logout()
    {
        // Logic to handle logging out the user
        return SignOut();
    }
}

Давайте разберем код этого класса подробнее:

  • Атрибут [Route("[controller]")] задает базовый маршрут для всех действий в контроллере. В данном случае маршрут будет совпадать с именем контроллера, то есть все пути к методам нашего API будут начинаться с /bff/.

  • Константа CorsPolicyName = "Bff" определяет имя политики CORS (Cross-Origin Resource Sharing) для использования в атрибутах методов. Мы будем ссылаться на нее в дальнейшем.

  • Три метода CheckSession, Login и Logout реализуют необходимый функционал BFF, описанный выше. Они обрабатывают GET-запросы по путям /bff/check_session, /bff/login и POST-запрос по пути /bff/logout соответственно.

  • Метод CheckSession проверяет аутентификацию пользователя. Если пользователь не аутентифицирован, возвращается код 401 Unauthorized, что должно заставить SPA перейти к аутентификации. В случае, если аутентификация успешна, метод возвращает словарь из claim-ов и их значений. Также метод содержит привязку к политике CORS с именем CorsPolicyName, т.к. вызов этого метода может быть кросс-доменным и содержать cookie, с помощью которых происходит аутентификация пользователя.

  • Метод Login вызывается SPA, если предыдущий вызов CheckSession вернул 401 Unauthorized. Он дополнительно удостоверяется в том, что пользователь все еще не аутентифицирован, и запускает настроенный процесс Challenge, в результате которого произойдет перенаправление на сервер OpenID Connect, аутентификация пользователя с использованием Authorization Code Flow + PKCE и выпуск аутентификационной cookie. После этого управление вернется к корню нашего приложения "~/", что вызовет повторную загрузку и запуск SPA, теперь уже с аутентифицированным пользователем.

  • Метод Logout также вызывается SPA, но производит окончание текущей сессии аутентификации. Он удаляет аутентификационные cookie, выписанные серверной частью BffSample, а также вызывает End Session endpoint на стороне сервера OpenID Connect.

Настройка CORS для BFF

Как уже упоминалось ранее, метод CheckSession предназначен для асинхронного вызова со стороны SPA (обычно для этого используется Fetch API). Правильная работа этого метода зависит от возможности передачи аутентификационных cookies со стороны браузера. Если SPA загружается с отдельного Static Web Host, например CDN или dev-сервера в режиме отладки, работающего на отдельном порту, этот вызов становится кросс-доменным. Это делает необходимой настройку политики CORS, без которой SPA не сможет вызывать данный метод.

Мы уже указали в коде контроллера в файле ControllersBffController.cs на использование политики CORS с именем CorsPolicyName = "Bff". Пришло время настроить параметры этой политики для решения нашей задачи. Для этого вернемся к файлу BffSample/Program.cs и добавим в него следующие блоки кода:

// ******************* START *******************
using BffSample.Controllers;
// ******************** END ********************
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();

// ...

builder.Services
    .AddAuthorization()
    .AddAuthentication(options => configuration.Bind("Authentication", options))
    .AddCookie()
    .AddOpenIdConnect(options => configuration.Bind("OpenIdConnect", options));
// ******************* START *******************
builder.Services.AddCors(
    options => options.AddPolicy(
        BffController.CorsPolicyName,
        policyBuilder =>
        {
            var allowedOrigins = configuration.GetSection("CorsSettings:AllowedOrigins").Get<string[]>();

            if (allowedOrigins is { Length: > 0 })
                policyBuilder.WithOrigins(allowedOrigins);

            policyBuilder
                .WithMethods(HttpMethods.Get)
                .AllowCredentials();
        }));
// ******************** END ********************
var app = builder.Build();

Этот код разрешает вызов методов, связанных с CORS-политикой, из SPA, загруженных из источников, заданных в конфигурации массивом строк CorsSettings:AllowedOrigins, с помощью метода GET и позволяет передачу в этом вызове cookies. Также добавьте сразу перед app.UseAuthentication() вызов app.UseCors(...):

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
// ******************* START *******************
app.UseCors(BffController.CorsPolicyName);
// ******************** END ********************
app.UseAuthentication();
app.UseAuthorization();

Чтобы политика CORS работала корректно, добавьте в файл конфигурации BffSampleappsettings.Development.json соответствующую настройку:

{
  // ******************* START *******************
  "CorsSettings": {
    "AllowedOrigins": [ "https://localhost:3000" ]
  },
  // ******************** END ********************
 "OpenIdConnect": {
   "Authority": "https://localhost:5001",
   "ClientId": "bff_sample",

В нашем примере адрес https://localhost:3000 - это адрес, на котором с помощью команды npm run dev запускается dev-сервер с SPA на React. Узнать этот адрес в вашем случае можно, если открыть файл BffSample.csproj и найти там значение параметра SpaProxyServerUrl. В реальном приложении в политику CORS вместо него может быть вписан, например адрес вашей CDN (Content Delivery Network) или что-то подобное. Важно помнить, что если ваше SPA загружается не с того же самого адреса и порта, что предоставляет BFF API, то важно не забыть добавить этот адрес в настройку политики CORS.

Реализация аутентификации через BFF в приложении на React

Мы реализовали BFF API со стороны сервера. Пришло время переключить внимание на SPA на React и добавить в него соответствующий функционал, выполняющий вызовы этого API. Начнем с того, что перейдем в папку BffSampleClientAppsrc, создадим там папку components и добавим туда файл Bff.tsx со следующим содержанием:

import React, { createContext, useContext, useEffect, useState, ReactNode, FC } from 'react';

// Define the shape of the BFF context
interface BffContextProps {
    user: any;
    fetchBff: (endpoint: string, options?: RequestInit) => Promise<Response>;
    checkSession: () => Promise<void>;
    login: () => void;
    logout: () => Promise<void>;
}

// Creating a context for BFF to share state and functions across the application
const BffContext = createContext<BffContextProps>({
    user: null,
    fetchBff: async () => new Response(),
    checkSession: async () => {},
    login: () => {},
    logout: async () => {}
});

interface BffProviderProps {
    baseUrl: string;
    children: ReactNode;
}

export const BffProvider: FC<BffProviderProps> = ({ baseUrl, children }) => {
    const [user, setUser] = useState<any>(null);

    // Normalize the base URL by removing a trailing slash to avoid inconsistent URLs
    if (baseUrl.endsWith('/')) {
        baseUrl = baseUrl.slice(0, -1);
    }

    const fetchBff = async (endpoint: string, options: RequestInit = {}): Promise<Response> => {
        try {
            // The fetch function includes credentials to handle cookies, which are necessary for authentication
            return await fetch(`${baseUrl}/${endpoint}`, {
                credentials: 'include',
                ...options
            });
        } catch (error) {
            console.error(`Error during ${endpoint} call:`, error);
            throw error;
        }
    };

    // The login function redirects to the login page when user needs to authenticate
    const login = (): void => {
        window.location.replace(`${baseUrl}/login`);
    };

    // The checkSession function is responsible for verifying the user session on initial render
    const checkSession = async (): Promise<void> => {
        const response = await fetchBff('check_session');

        if (response.ok) {
            // If the session is valid, update the user state with the received claims data
            setUser(await response.json());
        } else if (response.status === 401) {
            // If the user is not authenticated, redirect him to the login page
            login();
        } else {
            console.error('Unexpected response from checking session:', response);
        }
    };

    // Function to log out the user
    const logout = async (): Promise<void> => {
        const response = await fetchBff('logout', { method: 'POST' });

        if (response.ok) {
            // Redirect to the home page after successful logout
            window.location.replace('/');
        } else {
            console.error('Logout failed:', response);
        }
    };

    // useEffect is used to run the checkSession function once the component mounts
    // This ensures the session is checked immediately when the app loads
    useEffect(() => { checkSession(); }, []);

    return (
        // Providing the BFF context with relevant values and functions to be used across the application
        <BffContext.Provider value={{ user, fetchBff, checkSession, login, logout }}>
            {children}
        </BffContext.Provider>
    );
};

// Custom hook to use the BFF context easily in other components
export const useBff = (): BffContextProps => useContext(BffContext);

// Export HOC to provide access to BFF Context
export const withBff = (Component: React.ComponentType<any>) => (props: any) =>
    <BffContext.Consumer>
        {context => <Component {...props} bffContext={context} />}
    </BffContext.Consumer>;

Этот файл экспортирует:

  • компонент-провайдер BffProvider, который создает контекст для BFF и предоставляет функции и состояние, связанные с аутентификацией и управлением сессией, для всего приложения.

  • пользовательский хук useBff(), который возвращает объект с текущим состоянием пользователя и функциями для работы с BFF: checkSession, login и logout. Он предназначен для использования в функциональных компонентах React.

  • компонент высшего порядка (Higher-Order Component, HOC) withBff для использования в классовых компонентах React.

Теперь создадим компонент UserClaims, который в случае успешной аутентификации будет получать и показывать список claim-ов текущего пользователя. Создадим в BffSampleClientAppsrccomponents файл UserClaims.tsx и поместим туда следующий код:

import React from 'react';
import { useBff } from './Bff';

export const UserClaims: React.FC = () => {
    const { user } = useBff();

    if (!user)
        return <div>Checking user session...</div>;

    return (
        <>
            <h2>User Claims</h2>
            {Object.entries(user).map(([claim, value]) => (
                <div key={claim}>
                    <strong>{claim}</strong>: {String(value)}
                </div>
            ))}
        </>
    );
};

Этот код проверяет наличие аутентифицированного пользователя с помощью хука useBff() и в случае успеха выводит его claim-ы в виде списка. Если данные о пользователе еще недоступны, код выводит текст Checking user session....

Перейдем теперь к файлу BffSampleClientAppsrcApp.tsx. Заменим его содержимое на нужное нам.
Импортируем в начале BffProvider из файла components/Bff.tsx и UserClaims из components/UserClaims.tsx и вставим основной код компонента:

import { BffProvider, useBff } from './components/Bff'
import { UserClaims } from './components/UserClaims'

const LogoutButton: React.FC = () => {
    const { logout } = useBff();
    return (
        <button className="logout-button" onClick={logout}>
            Logout
        </button>
    );
};

const App: React.FC = () => (
    <BffProvider baseUrl="https://localhost:5003/bff">
        <div className="card">
            <UserClaims/>
        </div>
        <div className="card">
            <LogoutButton />
        </div>
    </BffProvider>
);

export default App

Здесь в параметре baseUrl задан базовый URL нашего BFF API https://localhost:5003/bff. Это упрощение сделано сознательно. В реальном приложении вместо жестко заданного значения следует предусмотреть динамическое задание этой настройки вместо хард-кода. Этого можно добиться различными способами, но их рассмотрение выходит за рамки основной темы этой статьи.

Кнопка Logout обеспечивает пользователю возможность выйти из учетной записи. Она вызывает функцию logout, которая доступна с помощью хука useBff и перенаправляет браузер пользователя на endpoint /bff/logout, который завершает пользовательскую сессию на стороне сервера.

На этом этапе уже можно запустить приложение BffSample совместно с OpenIDProviderApp и протестировать его работу. Для запуска можно воспользоваться командой dotnet run -lp https в каждом из проектов или помощью вашей любимой IDE. Важно, чтобы оба приложения должны быть запущены одновременно.

После этого откройте браузер и перейдите на URL https://localhost:5003. Если все сделано правильно, в браузере загрузится SPA, которое сделает вызов на /bff/check_session. Endpoint /check_session вернет код ответа 401, в ответ на которое SPA переадресует браузер на /bff/login, а тот в свою очередь инициирует аутентификацию на сервере через OpenID Connect Authorization Code Flow с использованием PKCE. Всю последовательность этих запросов можно увидеть, если открыть Development Console вашего браузера и перейти на вкладку Network. После успешного ввода учетных данных пользователя (john.doe@example.com, Jd!2024$3cur3) управление вернется к SPA, и мы увидим в браузере список claim-ов аутентифицированного пользователя:

sub: 1234567890
sid: V14fb1VQbAFG6JXTYQp3D3Vpa8klMLcK34RpfOvRyxQ
auth_time: 1717852776
name: John Doe
email: john.doe@example.com

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

В случае каких-либо ошибок, вы можете сравнить свой код с нашим репозитарием на GitHub Abblix/Oidc.Server.GettingStarted, который содержит этот и другие примеры в готовом к запуску виде.

Решение проблем с доверием к HTTPS сертификатам

При локальном тестировании веб-приложений, которые настроены на работу по HTTPS, вы можете столкнуться с предупреждениями браузера о том, что SSL-сертификат не является доверенным. Эта проблема возникает потому, что сертификаты разработки, используемые ASP.NET Core, не выданы признанным удостоверяющим центром (Certification Authority, CA), а являются самоподписанными или отсутствуют в системе вовсе. Устранить эти предупреждения можно, однократно выполнив команду:

dotnet dev-certs https --trust

Она сгенерирует самоподписанный сертификат для localhost и установит его в вашу систему, чтобы та доверяла этому сертификату. Сертификат будет использован ASP.NET Core для запуска веб-приложений локально. После выполнения этой команды перезапустите браузер, чтобы изменения вступили в силу.

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

  • Щелкните в любом месте на странице с ошибкой и введите thisisunsafe или badidea, в зависимости от версии Chrome. Эти последовательности клавиш действуют как команды обхода в Chrome, позволяя вам перейти на сайт localhost.

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

Вызов сторонних API через BFF

Мы уже добились того, что в нашем приложении BffSample заработала аутентификация. А что насчет вызова некого стороннего API, доступ к которому предоставляется по access-токену? Давайте теперь реализуем и эту функциональность.

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

Создание сервиса ApiSample

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

BFF с YARP

BFF с YARP

Для создания приложения мы воспользуемся готовым шаблоном, предоставляемым .NET. Перейдем в папку с проектами OpenIDProviderApp и BffSample и выполним команду для создания приложения ApiSample:

dotnet new webapi -n ApiSample

Это приложение на базе на ASP.NET Core Minimal API обслуживает единственный endpoint /weatherforecast, который отдает данные о погоде в формате json.

Сразу заменим в приложении ApiSample назначенный случайным образом номер порта, на котором оно запускается локально, на фиксированный 5004. Как уже говорилось ранее, это действие не является обязательным, но оно упростит нам дальнейшую настройку. Для этого в файле ApiSamplePropertieslaunchSettings.json найдем профиль с именем https и заменим значение свойства applicationUrl на https://localhost:5004.

Теперь сделаем API погоды доступным только при предъявлении токена доступа. Сначала перейдем в папку проекта ApiSample и подключим nuget-пакет, реализующий аутентификацию с помощью JWT Bearer токенов:

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

Настроим в файле ApiSampleProgram.cs схему аутентификации и политику авторизации с именем WeatherApi:

// ******************* START *******************
using System.Security.Claims;
// ******************** END ********************
var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// ******************* START *******************
var configuration = builder.Configuration;

builder.Services
    .AddAuthentication()
    .AddJwtBearer(options => configuration.Bind("JwtBearerAuthentication", options));

const string policyName = "WeatherApi";

builder.Services.AddAuthorization(
    options => options.AddPolicy(policyName, policy =>
    {
        policy.RequireAuthenticatedUser();
        policy.RequireAssertion(context =>
        {
            var scopeValue = context.User.FindFirstValue("scope");
            if (string.IsNullOrEmpty(scopeValue))
                return false;

            var scope = scopeValue.Split(' ', StringSplitOptions.RemoveEmptyEntries);
            return scope.Contains("weather", StringComparer.Ordinal);
        });
    }));
// ******************** END ********************
var app = builder.Build();

Этот блок кода настраивает аутентификацию, считывая настройки из конфигурации приложения, включает авторизацию с использованием JWT (JSON Web Tokens) и настраивает политику авторизации. Политика авторизации с именем WeatherApi задает следующие требования:

  • policy.RequireAuthenticatedUser(): гарантирует, что запросы к защищенным ресурсам могут выполняться только аутентифицированными пользователями.

  • policy.RequireAssertion(context => ...): Пользователь должен иметь claim scope, который включает значение weather. Поскольку согласно RFC 8693 claim scope может содержать набор из нескольких значений в произвольном порядке, разделенных пробелами, то актуальное значение scope разбивается на отдельные части, после чего проверяется, содержит ли получившийся массив требуемое значение weather.

Вместе эти условия гарантируют, что только аутентифицированные пользователи, имеющие токен доступа и авторизованные для получения weather, могут выполнять вызовы endpoint-а, для которого задана данная политика.

Нам необходимо применить эту политику к endpoint-у /weatherforecast. Добавим вызов метода RequireAuthorization(), как указано ниже:

app.MapGet("/weatherforecast", () =>
{

// ...

})
.WithName("GetWeatherForecast")
// ******************* START *******************
.WithOpenApi()
.RequireAuthorization(policyName);
// ******************** END ********************

Добавим в файл appsettings.Development.json нашего приложения ApiSample необходимые настройки для корректной работы схемы аутентификации:

{
  // ******************* START *******************
  "JwtBearerAuthentication": {
    "Authority": "https://localhost:5001",
    "MapInboundClaims": false,
    "TokenValidationParameters": {
      "ValidTypes": [ "at+jwt" ],
      "ValidAudience": "https://localhost:5004",
      "ValidIssuer": "https://localhost:5001"
    }
  },
  // ******************** END ********************
  "Logging": {
    "LogLevel": {
      "Default": "Information",

Давайте рассмотрим каждую из настроек подробнее:

  • Authority - это URL, указывающий на сервер авторизации OpenID Connect, который выдает JWT токены. Провайдер аутентификации, который мы настраиваем в приложении ApiSample, будет использовать этот URL для получения информации, необходимой для проверки токенов, например открытых ключей подписи.

  • Настройка MapInboundClaims управляет тем, как входящие claim-ы из JWT токена будут отображаться на внутренние claim-ы ASP.NET Core. Она установлена в false, что означает что claim-ы будут использовать свои оригинальные имена из JWT.

  • Секция TokenValidationParameters задает настройки:

    • ValidTypes со значением at+jwt, что согласно RFC 9068 2.1 указывает на Access Token в формате JWT.

    • ValidAudience указывает, что приложение будет принимать токены, выписанные для клиента https://localhost:5004.

    • ValidIssuer указывает, что приложение будет принимать токены, выписанные сервером https://localhost:5001.

Дополнительная настройка OpenIDProviderApp

Связка из сервиса аутентификации OpenIDProviderApp и клиента BffSample успешно работает, обеспечивая аутентификацию пользователя. Однако для вызова удаленного API нам необходимо дополнительно зарегистрировать наше приложение ApiSample на стороне OpenIDProviderApp в качестве ресурса. В нашем примере мы используем сервер на базе Abblix OIDC Server, который поддерживает RFC 8707: Resource Indicators for OAuth 2.0, поэтому мы зарегистрируем приложение ApiSample в качестве ресурса с областью действия (scope) weather. В случае использования другого сервера OpenID Connect без поддержки функционала Resource Indicators все равно рекомендуется зарегистрировать как минимум уникальный scope для этого удаленного API (weather в нашем примере).

Добавим в файл OpenIDProviderAppProgram.cs следующий код:

// Register and configure Abblix OIDC Server
builder.Services.AddOidcServices(options => {
    // ******************* START *******************
    options.Resources =
    [
        new(new Uri("https://localhost:5004", UriKind.Absolute), new ScopeDefinition("weather")),
    ];
    // ******************** END ********************
    options.Clients = new[] {
        new ClientInfo("bff_sample") {

В данном примере мы регистрируем приложение ApiSample, указывая его базовый адрес https://localhost:5004 в качестве ресурса и определяя для него scope с именем weather. В реальных приложениях, особенно для сложных API, состоящих из множества endpoint-ов, будет целесообразно разделить различные области действия для каждого отдельного endpoint-а или совокупности связанных между собой endpoint-ов. Это позволяет более точно контролировать доступ и обеспечивает гибкость в управлении правами доступа. Например, можно определить отдельные области действия для различных операций, модулей приложения или уровней доступа пользователей.

Доработка BffSample для проксирования запросов к удаленному API

Для клиента BffSample теперь необходимо не только запрашивать токен доступа к ApiSample, но и научиться принимать запросы от SPA к удаленному API, добавляя к ним полученный от сервиса OpenIDProviderApp токен доступа и пересылать их на удаленный сервер, возвращая ответы от сервера обратно в SPA, т.е. выполнять функцию обратного прокси-сервера.

Вместо того чтобы реализовывать в нашем клиентском приложении проксирование запросов вручную, мы воспользуемся готовым продуктом YARP (Yet Another Reverse Proxy), разработанным компанией Microsoft. YARP представляет собой обратный прокси-сервер, написанный на .NET и поставляемый в виде nuget-пакета.

Для использования YARP в приложении BffSample сначала подключим nuget-пакет:

dotnet add package Yarp.ReverseProxy

А после в файл BffSampleProgram.cs добавим в самом начале следующие пространства имен:

using Microsoft.AspNetCore.Authentication;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using System.Net.Http.Headers;
using Yarp.ReverseProxy.Transforms;

Перед вызовом var app = builder.Build(); добавим код:

builder.Services.AddHttpForwarder();

а между вызовами методов app.MapControllerRoute() и app.MapFallbackToFile():

app.MapForwarder(
    "/bff/{**catch-all}",
    configuration.GetValue<string>("OpenIdConnect:Resource") ?? throw new InvalidOperationException("Unable to get OpenIdConnect:Resource from current configuration"),
    builderContext =>
    {
        // Cut the "/bff" prefix from the request path
        builderContext.AddPathRemovePrefix("/bff");

        builderContext.AddRequestTransform(async transformContext =>
        {
            // Get the access token received earlier during the authentication process
            var accessToken = await transformContext.HttpContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken);
            
            // Append a header with access token to the proxy request
            transformContext.ProxyRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
        });
    }).RequireAuthorization();

Давайте разберемся, что делает этот код:

  • builder.Services.AddHttpForwarder() регистрирует необходимые для YARP сервисы в DI-контейнере.

  • app.MapForwarder настраивает перенаправление запросов (forwarding) к другому серверу или endpoint-у.

  • "/bff/{**catch-all}" - это шаблон пути, на который будет реагировать обратный прокси. Все запросы, начинающиеся с /bff/, будут обработаны YARP. {**catch-all} используется для захвата всех оставшихся частей URL после /bff/.

  • configuration.GetValue<string>("OpenIdConnect:Resource") - здесь используется конфигурация приложения для получения значения из секции OpenIdConnect:Resource. Это значение указывает на адрес ресурса, к которому будут перенаправлены запросы. В нашем примере этим значением будет https://localhost:5004 - базовый URL, на котором работает приложение ApiSample.

  • builderContext => ... добавляет необходимые нам трансформации, которые YARP будет выполнять над каждым входящим запросом от SPA. Таких трансформаций в нашем случае будет две:

    • builderContext.AddPathRemovePrefix("/bff") удаляет префикс /bff из исходного пути запроса.

    • builderContext.AddRequestTransform(async transformContext => ...) добавляет к запросу HTTP-заголовок Authorization, содержащий токен доступа, который был ранее получен в процессе аутентификации. Таким образом запросы от SPA к удаленному API будут аутентифицированы с помощью токена доступа, не смотря на то, что само SPA не имеет доступа к этому токену.

  • .RequireAuthorization() указывает, что для всех перенаправленных запросов требуется авторизация. Только авторизованные пользователи смогут получить доступ к маршруту /bff/{**catch-all}.

Для того, чтобы при аутентификации был запрошен токен доступа к ресурсу https://localhost:5004, необходимо добавить в конфигурацию OpenIdConnect в файле BffSample/appsettings.Development.json параметр Resource со значением https://localhost:5004:

  "OpenIdConnect": {
    // ******************* START *******************
    "Resource": "https://localhost:5004",
    // ******************** END ********************
    "Authority": "https://localhost:5001",
    "ClientId": "bff_sample",

Также добавьте в файле BffSample/appsettings.json в массив параметров scope еще одно значение - weather:

{
  "OpenIdConnect": {

    // ...

    // ******************* START *******************
    "Scope": ["openid", "profile", "email", "weather"],
    // ******************** END ********************

    // ...

  }
}

Внимание: В реальном проекте также необходимо следить за истечением срока действия токена доступа и либо заблаговременно запрашивать у сервиса аутентификации новый токен доступа, используя токен обновления (refresh token), либо обрабатывать ошибку отказа в доступе от удаленного API, после чего запрашивать новый токен и повторять исходный запрос к удаленному API. В целях сокращения объема статьи мы сознательно пропустим данный аспект проблемы.

Запрос API погоды через BFF в приложении SPA

Итак, со стороны сервера всё готово. Есть приложение ApiSample, реализующее API с авторизацией по токенам доступа, есть обратный прокси-сервер, встроенный в приложение BffSample, обеспечивающий доступ к нему. Осталось добавить в SPA на React функционал запроса этого API и отображение полученных данных.

Добавим в BffSampleClientAppsrccomponents файл WeatherForecast.tsx со следующим содержимым:

import React, { useEffect, useState } from 'react';
import { useBff } from "./Bff";

interface Forecast {
    date: string;
    temperatureC: number;
    temperatureF: number;
    summary: string;
}

interface State {
    forecasts: Forecast[];
    loading: boolean;
}

export const WeatherForecast: React.FC = () => {
    const { fetchBff } = useBff();
    const [state, setState] = useState<State>({ forecasts: [], loading: true });
    const { forecasts, loading } = state;

    useEffect(() => {
        fetchBff('weatherforecast')
            .then(response => response.json())
            .then(data => setState({forecasts: data, loading: false}));
    }, []);


    const contents = loading
        ? <p><em>Loading...</em></p>
        : (
            <table className="table table-striped" aria-labelledby="tableLabel">
                <thead>
                <tr>
                    <th>Date</th>
                    <th>Temp. (C)</th>
                    <th>Temp. (F)</th>
                    <th>Summary</th>
                </tr>
                </thead>
                <tbody>
                {forecasts.map((forecast, index) => (
                    <tr key={index}>
                        <td>{forecast.date}</td>
                        <td align="center">{forecast.temperatureC}</td>
                        <td align="center">{forecast.temperatureF}</td>
                        <td>{forecast.summary}</td>
                    </tr>
                ))}
                </tbody>
            </table>
        );

    return (
        <div>
            <h2 id="tableLabel">Weather forecast</h2>
            <p>This component demonstrates fetching data from the server.</p>
            {contents}
        </div>
    );
};

Разберем этот код подробно:

Интерфейс Forecast описывает структуру данных о прогнозе погоды (дата, температура в Цельсиях и Фаренгейтах, описание), а интерфейс State - структуру состояния компонента, включающего массив прогнозов погоды и флаг загрузки.

Компонент WeatherForecast извлекает функцию fetchBff из хука useBff и использует её для получения данных с сервера. Состояние компонента поддерживается с помощью хука useState, начальное состояние которого содержит пустой массив прогнозов и флаг загрузки, установленный в true.

Хук useEffect при монтировании компонента вызывает один раз функцию fetchBff для получения данных о прогнозах погоды с сервера по пути /bff/weatherforecast. После получения ответа от сервера и преобразования его в JSON, данные сохраняются в состоянии компонента (setState), и флаг загрузки устанавливается в false.

В зависимости от состояния загрузки (loading), компонент отображает либо сообщение "Loading...", либо таблицу с данными о прогнозе погоды, которая содержит дату, температуру в Цельсиях и Фаренгейтах, а также описание погоды для каждого прогноза.

Осталось добавить компонент WeatherForecast в BffSampleClientAppsrcApp.tsx:

// ******************* START *******************
import { WeatherForecast } from "./components/WeatherForecast";
// ******************** END ********************

// ...

    <div className="card">
        <UserClaims/>
    </div>
    // ******************* START *******************
    <div className="card">
        <WeatherForecast/>
    </div>
    // ******************** END ********************
    <div className="card">
        <button className="logout-button" onClick={logout}>
            Logout
        </button>
    </div>

Запуск и проверка работы

Теперь, если все сделано без ошибок, можно запустить все три наших проекта. Для этого можно воспользоваться консольной командой dotnet run -lp https для каждого из приложений.

После запуска всех трех приложений, откройте в браузере приложение BffSample и пройдите аутентификацию (john.doe@example.com, Jd!2024$3cur3). Успешно аутентифицировавшись, вы увидите список claim-ов, полученных от сервера аутентификации, который мы уже видели ранее. И чуть ниже - прогноз погоды.

За прогноз погоды отвечает отдельное приложение ApiSample, для доступа к которому используется токен доступа, выпущенный сервисом аутентификации OpenIDProviderApp. То, что мы видим прогноз погоды в окне приложения BffSample, свидетельствует о том, что наше SPA вызвало серверную часть BffSample, которая проксировала вызов к ApiSample, добавив к нему токен доступа. На своей стороне ApiSample успешно аутентифицировало вызов и в ответ отправило JSON с прогнозом погоды.

Готовое решение доступно на GitHub

Если вы столкнулись с проблемами или совершили ошибку в реализации тестовых проектов, вы можете свериться с готовым решением в репозитории на GitHub. Для этого клонируйте репозиторий Abblix/Oidc.Server.GettingStarted и получите доступ к реализованным проектам, описанным в этой статье. Этот ресурс можно использовать для поиска и решения проблем, а также как отправную точку для создания собственных проектов.

Заключение

Эволюция протоколов аутентификации OAuth 2.0 и OpenID Connect отражает более широкие тенденции в области веб-безопасности и возможностей браузеров. Переход от устаревших методов аутентификации, таких как Implicit Flow, к более безопасным подходам, таким как поток Authorization Code с PKCE, повышает уровень безопасности. Однако не смотря на все принимаемые меры, необходимость работать в неконтролируемом программно-аппаратном окружении заставляет считать современные SPA априори уязвимыми. Решение заключается в хранении токенов только на стороне бекенда и организации взаимодействия по шаблону Backend-For-Frontend, что позволяет снизить риски и обеспечить надежную защиту данных пользователя.

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

В этой статье мы не только разобрали актуальный подход к интеграции OpenID Connect, BFF и SPA, но и реализовали его на практике, использовав один из популярных на сегодня технологических стеков на базе .NET и React. Вы можете использовать этот подход как отправную точку для создания своих проектов.

Мы также приглашаем вас посетить наш репозиторий на GitHub для участия в развитии современного решения для аутентификации. Спасибо за внимание!

Автор: kirill-abblix

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js