ASP.NET Core: сервер аутентификации с OpenID Connect

Введение

В этой (немного длинной) заметке мы рассмотрим основные концепции OpenID Connect и пример реализации в ASP.NET Core.

Для реализации OpenID Connect в .NET мы будем использовать OpenIddict-core и .NET 6.

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

Вы можете следить за мной в Twitter @balunatic, где я обычно публикую материалы по программированию, но если у вас есть сомнения или что-то еще, отправьте мне DM 😁.

Этот пост в основном разделен на две части:

  • Теория OpenID Connect

  • Реализация протокола в ASP.NET Core

Протокол OpenID Connect

Что такое OpenID Connect (OIDC)?

OpenID Connect (или OIDC) — это протокол идентификации, который использует механизмы аутентификации и авторизации OAuth 2.0. Окончательная спецификация OIDC была опубликована в феврале 2014 года, и на сегодняшний день она принята большим количеством поставщиков идентификационных данных.

OAuth 2.0 является протоколом авторизации, а OIDC — протоколом аутентификации и используется для проверки личности пользователя в сторонней службе (известной как Relying Party).

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

Ваше веб-приложение является той самой Relying Party, и это приложение не должно иметь учетные данные Google или Facebook пользователя, поскольку мы стремимся не раскрывать эту информацию, и благодаря OIDC нам не нужно ими манипулировать. Следуя правильной схеме, OIDC помогает аутентифицировать пользователя без необходимости создания новой учетной записи в вашем веб-приложении и повторного использования существующих учетных записей из популярных сервисов (опять же, таких как google или facebook).

Самые разные клиенты могут использовать OpenID Connect для аутентификации пользователей, от одностраничных приложений (SPA, например, Angular или React) до нативных мобильных приложений. Он также используется в Single Sign On во многих приложениях (SSO очень полезен, когда в вашей организации есть широкий спектр услуг или внутренних приложений, и вам нужно использовать одну и ту же учетную запись).

Различия между OAuth 2.0 и OIDC

OIDC фактически является дополнительной функциональностью к тому, что уже существует в OAuth 2.0. В то время как OAuth 2.0 посвящен механизму авторизации доступа к информации, OIDC фокусируется на личности пользователя (аутентификация).

Основная цель OIDC — предоставить вам единое место для входа в систему независимо от того, сколько у вас сайтов. То есть, когда вы заходите в свое приложение, требующее аутентифицированного пользователя, вы направляетесь на ваш сервер идентификации (который использует OpenID), и когда вы входите в систему, вы перенаправляетесь обратно в приложение, с которого вы начали, но уже как аутентифицированный пользователь. Учетные данные и ваша личная информация хранятся у поставщика идентификационных данных, и сторонним приложениям достаточно знать, являетесь ли вы тем, за кого себя выдаете.

Однако OAuth фокусируется на защите информации и ограничении доступа. Например: когда вы переходите на сайт myapp.com и входите в систему с помощью Facebook, используется OpenID. Но когда myapp.com спросит вас Хотите ли вы импортировать ваши контакты из Facebook в myapp.com? тогда будет использоваться OAuth, потому что Facebook спросит вас Разрешить myapp.com доступ к вашему списку контактов? и это «согласие» будет осуществляться в соответствии с правилами OAuth.

Выберите правильный поток аутентификации

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

Неинтерактивные потоки

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

 Пароль владельца ресурса (не рекомендуется для новых приложений)

Этот поток напрямую вдохновлен базовой аутентификацией (где учетные данные отправляются в заголовке, закодированном в base 64). В данном случае это самый простой поток, существующий в спецификации OAuth 2.0: клиентское приложение (Relying party) запрашивает имя пользователя/пароль, отправляет запрос на авторизацию поставщику идентификационных данных и немедленно возвращает маркер доступа, если он авторизован.

Примечание 💡: Этот поток не рекомендуется, так как имя пользователя и пароль напрямую открыты для клиентского приложения. По этой причине данный поток не следует использовать, когда задействованы сторонние приложения. Если вы разрабатываете внутренние приложения (вы все контролируете), для простоты это может сработать.

 Предоставление учетных данных клиента (рекомендуется для связи между машинами)

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

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

Интерактивные потоки

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

 Поток кода авторизации (рекомендуется для новых приложений)

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

Преимуществом этой сложности является то, что access_token никогда не проходит через браузер, поскольку бэкэнд осуществляет обмен информацией непосредственно с сервером аутентификации после проверки пользователя.

По сути, в этом потоке есть 2 важных этапа: запрос и ответ на конечную точку authorization и то же самое с конечной точкой token.

 Запрос на авторизацию

В этом потоке клиентское приложение начинает процесс аутентификации, генерируя запрос авторизации, всегда включающий параметр response_type=code, client_id, redirect_uri и, по желанию, scope и state (последнее помогает снизить уязвимость к атакам XSRF).

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

Если вход в систему осуществлен и разрешение получено, браузер (user-agent) перенаправляется обратно к клиентскому приложению, включая в качестве параметра в URL код авторизации (это маленький, уникальный и очень недолговечный токен), который используется только для обмена кода на access_token и id_token.

 Запрос токена

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

Это звучит как сложный процесс, но на самом деле это всегда одно и то же, поэтому существуют библиотеки или готовые фреймворки, которые уже делают это за нас.

 Неявный поток

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

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

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

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

Существует больше потоков, которые вам не нужно изучать.

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

Следующая таблица поможет нам лучше определиться:

Тип применения Поток OAuth 2.0
Серверная сторона (она же Web) Поток кодов авторизации
SPA Поток кода авторизации с PKCE или неявный поток (только если нет поддержки браузера для использования Crypto Web)
Родина Поток кодов авторизации с помощью PKCE
Проверенный Поток паролей владельца ресурса
Сервис Учетные данные клиента

Реализация сервера аутентификации в ASP.NET Core

Вы думали, мы закончили? Давайте перейдем к коду.

В этом посте мы рассмотрим, как создать 3 веб-приложения: Сервер аутентификации (провайдер идентификации с OpenID), клиентское веб-приложение (доверяющая сторона, которой нужны аутентифицированные пользователи и доступ к защищенным ресурсам) и веб-интерфейс (защищенный пользовательский ресурс, к которому хочет получить доступ клиентское веб-приложение).

Проект IdentityServer: он же сервер авторизации

Для этого нам нужно веб-приложение для управления пользователями. Для простоты мы будем использовать веб-шаблон asp.net core с индивидуальными учетными данными, которые использует Identity Core:

dotnet new razor --auth individual --use-local-db true -o IdentityServer
Войдите в полноэкранный режим Выход из полноэкранного режима

При использовании индивидуальных учетных записей будет использоваться Identity Core, а параметр local-db просто указывает, что мы хотим использовать SQLServer вместо SQLite (который используется по умолчанию).

Примечание 💡: Для лучшего ознакомления, вы можете проверить мой репозиторий GitHub и посмотреть этот полный пример.

Для этого сервера авторизации мы будем использовать очень хорошую библиотеку под названием OpenIddict (более простое решение, чем Duente’s Identity Server, но совершенно бесплатное для использования).

Мы устанавливаем OpenIddict, добавляя его пакеты:

<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="6.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="6.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="6.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="6.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.1" />
<!-- OpenIddict -->
<PackageReference Include="OpenIddict" Version="3.0.0" />
<PackageReference Include="OpenIddict.AspNetCore" Version="3.0.0" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="3.0.0" />
<!-- /OpenIddict -->
Войдите в полноэкранный режим Выход из полноэкранного режима

и начать настройку сервера из файла Program.cs.

Мы должны настроить OpenIddict на использование ApplicationDbContext, который шаблон добавил по умолчанию.

using IdentityServer.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using OpenIddict.Abstractions;

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
    options.UseSqlServer(connectionString);
    // Register the entity sets needed by OpenIddict.
    options.UseOpenIddict();
});
Войдите в полноэкранный режим Выход из полноэкранного режима

OpenIddict будет хранить в SQLServer (используя EF Core) всю необходимую информацию для клиентских приложений и выданных ими токенов.

После этого мы добавляем зависимости OpenIddict и разрешенные потоки:

builder.Services.AddOpenIddict()
    // Register the OpenIddict core components.
    .AddCore(options =>
    {
        // Configure OpenIddict to use the EF Core stores/models.
        options
            .UseEntityFrameworkCore()
            .UseDbContext<ApplicationDbContext>();
    })
    // Register the OpenIddict server components.
    .AddServer(options =>
    {
        options
            .AllowClientCredentialsFlow()
            .AllowAuthorizationCodeFlow()
            .RequireProofKeyForCodeExchange()
            .AllowRefreshTokenFlow();

        options
            .SetTokenEndpointUris("/connect/token")
            .SetAuthorizationEndpointUris("/connect/authorize")
            .SetUserinfoEndpointUris("/connect/userinfo");

        // Encryption and signing of tokens
        options
            .AddEphemeralEncryptionKey()
            .AddEphemeralSigningKey()
            .DisableAccessTokenEncryption();

        // Register scopes (permissions)
        options.RegisterScopes("api");
        options.RegisterScopes("profile");

        // Register the ASP.NET Core host and configure the ASP.NET Core-specific options.
        options
            .UseAspNetCore()
            .EnableTokenEndpointPassthrough()
            .EnableAuthorizationEndpointPassthrough()
            .EnableUserinfoEndpointPassthrough();
    });
Войдите в полноэкранный режим Выход из полноэкранного режима

Здесь мы делаем следующее:

  • Мы включаем поток авторизации учетных данных клиента и кода авторизации, любой поток, который вы попытаетесь использовать иначе, запрос будет отклонен.
    • Мы также разрешаем PKCE и фактически делаем его обязательным для применения (потому что он более безопасен, особенно в SPA).
    • Также включена возможность использования Refresh Tokens.
  • Маршруты для конечных точек устанавливаются:
    • Жетон. Для обмена кода авторизации на токены доступа и идентификационные токены.
    • Авторизация. Запросить код авторизации после входа в систему.
    • Информация о пользователе. Чтобы запросить дополнительную информацию о пользователе после аутентификации.
  • При использовании AddEphemeralEncryptionKey и AddEphemeralSigninKey генерируется асимметричный RSA-ключ для режима разработки, поскольку он нигде не хранится и не передается между экземплярами.
    • Примечание: при каждом перезапуске сервера генерируются новые ключи. Используется только для тестирования.
  • Используемые диапазоны зарегистрированы, они работают как разрешения.
  • Методы Passthrough предназначены для того, чтобы мы предпринимали действия на этих конечных точках после их проверки OpenIddict.

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

builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
    .AddEntityFrameworkStores<ApplicationDbContext>();
builder.Services.AddRazorPages();
builder.Services.AddControllers();
builder.Services.AddDatabaseDeveloperPageExceptionFilter();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseMigrationsEndPoint();
}
else
{
    app.UseExceptionHandler("/Error");
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

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

app.MapRazorPages();
app.MapControllers();

await SeedDefaultClients();

app.Run();
Войдите в полноэкранный режим Выход из полноэкранного режима

Сначала нам нужно иметь возможность создавать клиентов OpenID, поэтому у нас есть метод Seed, описанный ниже:

async Task SeedDefaultClients()
{
    using var scope = app.Services.CreateScope();

    var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
    var manager = scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();

    await context.Database.EnsureCreatedAsync();

    var client = await manager.FindByClientIdAsync("clientwebapp");

    if (client is null)
    {
        await manager.CreateAsync(new OpenIddictApplicationDescriptor
        {
            ClientId = "clientwebapp",
            ClientSecret = "client-web-app-secret",
            DisplayName = "ClientWebApp",
            RedirectUris = { new Uri("https://localhost:7003/signin-oidc") },
            Permissions =
            {
                OpenIddictConstants.Permissions.Endpoints.Authorization,
                OpenIddictConstants.Permissions.Endpoints.Token,

                OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,
                OpenIddictConstants.Permissions.GrantTypes.ClientCredentials,
                OpenIddictConstants.Permissions.GrantTypes.RefreshToken,

                OpenIddictConstants.Permissions.Prefixes.Scope + "api",
                OpenIddictConstants.Permissions.Prefixes.Scope + "profile",
                OpenIddictConstants.Permissions.ResponseTypes.Code
            }
        });
    }
}
Войдите в полноэкранный режим Выход из полноэкранного режима

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

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

Шаблон уже содержит UI для создания пользователей (регистрация) и входа в систему, все с использованием стандартной реализации Identity Core, поэтому в этой части нам не нужно ничего реализовывать.

Что нам действительно нужно делать, так это генерировать утверждения (в соответствии с аутентифицированным пользователем) и проверять полученные запросы.

Для этого мы создадим следующий контроллер:

using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using System.Security.Claims;

namespace IdentityServer.Controllers;

public class AuthorizationController : Controller
{
    [HttpGet("~/connect/authorize")]
    [HttpPost("~/connect/authorize")]
    [IgnoreAntiforgeryToken]
    public async Task<IActionResult> Authorize()
    {
        var request = HttpContext.GetOpenIddictServerRequest() ??
                      throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");

        // Retrieve the user principal stored in the authentication cookie.
        var result = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme);

        // If the user principal can't be extracted, redirect the user to the login page.
        if (!result.Succeeded)
        {
            return Challenge(
                authenticationSchemes: IdentityConstants.ApplicationScheme,
                properties: new AuthenticationProperties
                {
                    RedirectUri = Request.PathBase + Request.Path + QueryString.Create(
                        Request.HasFormContentType ? Request.Form.ToList() : Request.Query.ToList())
                });
        }

        // Create a new claims principal

        var claims = new List<Claim>
            {
                // 'subject' claim which is required
                new Claim(OpenIddictConstants.Claims.Subject, result.Principal.Identity.Name),
                new Claim(OpenIddictConstants.Claims.Username, result.Principal.Identity.Name),
                new Claim(OpenIddictConstants.Claims.Audience, "test"),
            };

        var email = result.Principal.Claims.FirstOrDefault(q => q.Type == ClaimTypes.Email);
        if (email is not null)
        {
            claims.Add(new Claim(OpenIddictConstants.Claims.Email, email.Value));
        }

        var claimsIdentity = new ClaimsIdentity(claims, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);

        var claimsPrincipal = new ClaimsPrincipal(claimsIdentity);

        // Set requested scopes (this is not done automatically)
        claimsPrincipal.SetScopes(request.GetScopes());

        foreach (var claim in claimsPrincipal.Claims)
        {
            claim.SetDestinations(claim.Type switch
            {
                // If the "profile" scope was granted, allow the "name" claim to be
                // added to the access and identity tokens derived from the principal.
                OpenIddictConstants.Claims.Name when claimsPrincipal.HasScope(OpenIddictConstants.Scopes.Profile) => new[]
                {
                    OpenIddictConstants.Destinations.AccessToken,
                    OpenIddictConstants.Destinations.IdentityToken
                },

                // Never add the "secret_value" claim to access or identity tokens.
                // In this case, it will only be added to authorization codes,
                // refresh tokens and user/device codes, that are always encrypted.
                "secret_value" => Array.Empty<string>(),

                // Otherwise, add the claim to the access tokens only.
                _ => new[]
                {
                    OpenIddictConstants.Destinations.AccessToken
                }
            });
        }

        // Signing in with the OpenIddict authentiction scheme trigger OpenIddict to issue a code (which can be exchanged for an access token)
        return SignIn(claimsPrincipal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
    }

    [HttpPost("~/connect/token")]
    public async Task<IActionResult> Exchange()
    {
        var request = HttpContext.GetOpenIddictServerRequest() ??
                      throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");

        ClaimsPrincipal claimsPrincipal;

        if (request.IsClientCredentialsGrantType())
        {
            // Note: the client credentials are automatically validated by OpenIddict:
            // if client_id or client_secret are invalid, this action won't be invoked.

            var identity = new ClaimsIdentity(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);

            // Subject (sub) is a required field, we use the client id as the subject identifier here.
            identity.AddClaim(OpenIddictConstants.Claims.Subject, request.ClientId ?? throw new InvalidOperationException());

            // Add some claim, don't forget to add destination otherwise it won't be added to the access token.
            identity.AddClaim("some-claim", "some-value", OpenIddictConstants.Destinations.AccessToken);

            claimsPrincipal = new ClaimsPrincipal(identity);

            claimsPrincipal.SetScopes(request.GetScopes());
        }
        else if (request.IsAuthorizationCodeGrantType())
        {
            // Retrieve the claims principal stored in the authorization code
            claimsPrincipal = (await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)).Principal;
        }
        else if (request.IsRefreshTokenGrantType())
        {
            // Retrieve the claims principal stored in the refresh token.
            claimsPrincipal = (await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)).Principal;
        }
        else
        {
            throw new InvalidOperationException("The specified grant type is not supported.");
        }

        // Returning a SignInResult will ask OpenIddict to issue the appropriate access/identity tokens.
        return SignIn(claimsPrincipal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
    }

    [Authorize(AuthenticationSchemes = OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)]
    [HttpGet("~/connect/userinfo")]
    public async Task<IActionResult> Userinfo()
    {
        var claimsPrincipal = (await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)).Principal;

        return Ok(new
        {
            Sub = claimsPrincipal.GetClaim(OpenIddictConstants.Claims.Subject),
            Name = claimsPrincipal.GetClaim(OpenIddictConstants.Claims.Subject),
            Occupation = "Developer",
            Age = 31
        });
    }
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Я рекомендую вам прочитать код, исследовать свои сомнения и отладить его шаг за шагом, чтобы лучше понять, как он работает, но вот краткое описание:

Разрешить

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

Если он действителен, то проверяется, аутентифицирован ли пользователь в данный момент, в противном случае отправляется запрос на аутентификацию с использованием логина, предоставленного Identity Core. Схема Cookie, которую добавляет Identity Core, — IdentityConstants.ApplicationScheme, и именно эту схему мы используем для отправки на вход.

Как только он войдет в систему, RedirectUri возвращает его обратно в этот метод Authorize(), и теперь поток будет продолжаться, генерируя утверждения, которые вы хотите, потому что у нас уже есть аутентифицированный пользователь.

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

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

Обмен

Этот метод, как следует из названия, используется для обмена кода авторизации на токены (как доступа, так и идентификации) в случае применения потока.

Этот пример также включает поток мандатов клиентов, которому для генерации токенов нужны только client_id и client_secret (речь идет о примере machine-2-machine). Это просто для справки, если вы хотите протестировать с этим потоком.

Информация о пользователе

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

Для чего все это?

То, что мы только что сделали, было настройкой нашего провайдера идентификации, если мы перейдем по URL https://localhost:7001/.well-known/openid-configuration (порты могут отличаться), мы получим следующую конфигурацию:

{
  "issuer": "https://localhost:7001/",
  "authorization_endpoint": "https://localhost:7001/connect/authorize",
  "token_endpoint": "https://localhost:7001/connect/token",
  "userinfo_endpoint": "https://localhost:7001/connect/userinfo",
  "jwks_uri": "https://localhost:7001/.well-known/jwks",
  "grant_types_supported": [
    "client_credentials",
    "authorization_code",
    "refresh_token"
  ],
  "response_types_supported": [
    "code"
  ],
  "response_modes_supported": [
    "form_post",
    "fragment",
    "query"
  ],
  "scopes_supported": [
    "openid",
    "offline_access",
    "api",
    "profile"
  ],
  "claims_supported": [
    "aud",
    "exp",
    "iat",
    "iss",
    "sub"
  ],
  "id_token_signing_alg_values_supported": [
    "RS256"
  ],
  "code_challenge_methods_supported": [
    "S256"
  ],
  "subject_types_supported": [
    "public"
  ],
  "token_endpoint_auth_methods_supported": [
    "client_secret_basic",
    "client_secret_post"
  ],
  "claims_parameter_supported": false,
  "request_parameter_supported": false,
  "request_uri_parameter_supported": false
}
Войдите в полноэкранный режим Выход из полноэкранного режима

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

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

Следующим шагом будет создание клиентского приложения.

WebClient Проект: Приложение Web Client.

Здесь мы создадим приложение Razor таким же образом, как и в предыдущем случае, но без аутентификации или чего-либо еще:

dotnet new razor -o WebClient
Войдите в полноэкранный режим Выход из полноэкранного режима

Примечание 💡: URL, используемые в конфигурации клиента, очень важны, так как они являются частью проверки, и если они не верны, процесс авторизации будет отклонен.

Для этого проекта нам понадобится следующий пакет:

<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.1" />
Войдите в полноэкранный режим Выход из полноэкранного режима

Это приложение Razor pages поставляется с шаблоном bootstrap по умолчанию, но без аутентификации. В файле Program.cs мы сделаем следующее:

using Microsoft.IdentityModel.Protocols.OpenIdConnect;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorPages();

builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = "Cookies";
    options.DefaultChallengeScheme = "oidc";
})
.AddCookie("Cookies", options =>
{
    options.Cookie.Name = ".ClientWebAppAuth";
})
.AddOpenIdConnect("oidc", options =>
{
    options.Authority = "https://localhost:7001";

    options.ClientId = "clientwebapp";
    options.ClientSecret = "client-web-app-secret";
    options.ResponseType = OpenIdConnectResponseType.Code;

    options.Scope.Add("api");
    options.Scope.Add("openid");
    options.Scope.Add("profile");

    options.SaveTokens = true;
    options.GetClaimsFromUserInfoEndpoint = true;
    options.TokenValidationParameters.NameClaimType = "name";
});

var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

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

app.MapRazorPages()
    .RequireAuthorization();

app.Run();
Войдите в полноэкранный режим Выход из полноэкранного режима

Здесь есть два важных момента, но это краткое изложение:

    • Власть. Это хост сервера аутентификации.
    • ClientId, Client secret и Response type. Эта информация должна соответствовать клиенту, созданному Seed. Пока мы разрешаем только response_type=code, поскольку в процессе авторизации мы собираемся возвращать только код авторизации, чтобы его можно было сразу использовать, как мы уже видели.
    • Область применения. Здесь мы добавляем нужные нам области, не всегда все они нужны, но именно они требуются приложению (в качестве примера, вы можете не запрашивать область профиля, если вам не нужна дополнительная информация о пользователе).

Мы обновляем Index.cshtml, чтобы иметь возможность видеть утверждения, которые были выданы нам сервером авторизации:

@page
@using Microsoft.AspNetCore.Authentication
@model IndexModel
@{
    ViewData["Title"] = "Home page";
}

<div class="text-center">
    <h1 class="display-4">Welcome</h1>
    <p>Learn about <a href="https://docs.microsoft.com/aspnet/core">building Web apps with ASP.NET Core</a>.</p>
</div>

@if(User.Identity!.IsAuthenticated)
{
    <h2>Welcome @User.Identity.Name</h2>

    <ul>
        @foreach(var claim in @User.Claims)
        {
            <li>@claim.Type: @claim.Value</li>
        }
        <li>access_token: @(await HttpContext.GetTokenAsync("access_token"))</li>
        <li>id_token: @(await HttpContext.GetTokenAsync("id_token"))</li>
    </ul>
}
Войдите в полноэкранный режим Выход из полноэкранного режима

 Тестирование аутентификации

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

Важно отметить, что мы должны сгенерировать соответствующие миграции Entity Framework и запустить их (с помощью dotnet ef) на IdentityServer.

В данном случае, поскольку мой IdentityServer не имеет больше информации обо мне, чем мой email, это имя используется в качестве моего имени, но мы можем расширить его и добавить больше информации (это тема для последующих обсуждений относительно Identity Core).

Проект ProtectedApi: Защищенный веб-интерфейс API

И, наконец, то, что мы хотим защитить, в данном случае — Web API.

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

С помощью аутентификации JWT и Bearer мы будем использовать маркеры доступа для проверки каждого запроса, поступающего к нашему Web API. Но прежде чем мы начнем, нам нужно создать еще один проект:

dotnet new web -o ProtectedApi
Войдите в полноэкранный режим Выход из полноэкранного режима

Чтобы использовать аутентификацию JWT в нашем API, нам нужен следующий пакет:

<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.1" />
Войдите в полноэкранный режим Выход из полноэкранного режима

Пример будет очень простым, у нас будет только одна конечная точка в нашем Web Api, которая требует аутентифицированного пользователя:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication("Bearer")
      .AddJwtBearer("Bearer", options =>
      {
          options.Authority = "https://localhost:7001";
          options.Audience = "IdentityServerWebClients";
      });
builder.Services.AddAuthorization();

var app = builder.Build();

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

app.MapGet("/me", (HttpRequest request) =>
{
    var user = request.HttpContext.User;

    return Results.Ok(new
    {
        Claims = user.Claims.Select(s => new
        {
            s.Type,
            s.Value
        }).ToList(),
        user.Identity.Name,
        user.Identity.IsAuthenticated,
        user.Identity.AuthenticationType
    });
})
.RequireAuthorization();

app.Run();
Войдите в полноэкранный режим Выход из полноэкранного режима

В этом случае схемой по умолчанию, настроенной для аутентификации, является Bearer. Мы указываем, кто является нашим поставщиком идентификационных данных (authority), чтобы помочь нам аутентифицировать входящие запросы.

Здесь происходит волшебство, потому что ProtectedApi на самом деле ничего не знает о закрытых или открытых ключах, но они нужны ему для проверки полученных токенов доступа. В этом случае, когда вы указываете авторитетный хост, он автоматически считывает конечную точку /.well-known, рассмотренную выше, и считывает открытые ключи RSA, необходимые для проверки токенов.

WebClient имеет токены доступа, поэтому нам нужно, чтобы после аутентификации он попытался получить доступ к самому ProtectedApi.

Поэтому мы добавляем следующую страницу Razor в WebClient под названием Me.cshtml:

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.Net.Http.Headers;

namespace WebClient.Pages
{
    public class MeModel : PageModel
    {
        private readonly HttpClient _http;

        public MeModel(IHttpClientFactory httpClientFactory)
        {
            _http = httpClientFactory.CreateClient();
        }

        public string RawJson { get; set; } = default!;

        public async Task OnGet()
        {
            var accessToken = await HttpContext.GetTokenAsync("access_token");

            _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);

            var response = await _http.GetAsync("https://localhost:7005/me");

            response.EnsureSuccessStatusCode();

            RawJson = await response.Content.ReadAsStringAsync();
        }
    }
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Здесь для примера это делается не очень практичным способом, но я хочу прояснить, как сделать HTTP вызов к API, защищенному токеном доступа, предоставленным провайдером идентификации (который, в свою очередь, хранится в cookie авторизации WebClient).

Если мы обратимся к этой странице (перейдя по адресу https://localhost:7003/me), то сможем увидеть результат, отобразив RawJson:

@page
@model WebClient.Pages.MeModel

<h2>Calling Protected API result:</h2>

@Model.RawJson
Войдите в полноэкранный режим Выход из полноэкранного режима

Если мы сделаем неаутентифицированный вызов или недействительный JWT, будет возвращен HTTP 401 Unauthorized.

Заключение

Это кажется сложной темой, но как только вы сделаете этот пример самостоятельно, вы увидите, что это просто, так как это делается один раз, а затем работает для аутентификации N приложений (добавление их соответствующих клиентов).

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

В качестве задачи добавьте Google или Facebook к IdentityServer, чтобы его можно было использовать в качестве опции также при входе в WebClient.

Еще одна задача, которую вы можете выполнить, это добавить Javascript-клиент с бэкендом, используя паттерн BFF (Backend For Frontend) или без бэкенда, используя вспомогательную библиотеку oidc-client-js.

Ссылки

  • Начало работы (openiddict.com)
  • Настройка сервера авторизации с OpenIddict — Часть V — OpenID Connect — Сообщество DEV 👩💻👨💻
  • Протокол OpenID Connect (auth0.com)
  • Что такое OpenID Connect и для чего он нужен? — Auth0
  • Потоки аутентификации и авторизации (auth0.com)
  • Какой поток OAuth 2.0 следует использовать? (auth0.com)
  • Обзор OAuth 2.0 и OpenID Connect | Okta Developer

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *