Pull to refresh

Имплементация OpenId Connect в ASP.NET Core при помощи IdentityServer4 и oidc-client

Reading time 21 min
Views 59K


Недавно мне потребовалось разобраться, как делается аутентификация на OpenId Connect на ASP.NET Core. Начал с примеров, быстро стало понятно, что чтения спецификации не избежать, затем пришлось уже перейти к чтению исходников и статей разработчиков. В результате возникло желание собрать в одном месте всё, что необходимо для того, чтобы понять, как сделать рабочую реализацию OpenId Connect Implicit Flow на платформе ASP.NET Core, при этом понимая, что Вы делаете.


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


Немного об OpenId Connect


Если Вы понимаете OpenId Connect, можете начинать читать со следующей части.


OpenId Connect (не путать с OpenId) — протокол аутентификации, построенный на базе протокола авторизации OAuth2.0. Дело в том, что задачу OAuth2 входят вопросы только авторизации пользователей, но не их аутентификации. OpenID Connect также задаёт стандартный способ получения и представления профилей пользователей в качестве набора значений, называемых claims. OpenId Connect описывает UserInfo endpoint, который возвращает эти информацию. Также он позволяет клиентским приложениям получать информацию о пользователе в форме подписанного JSON Web Token (JWT), что позволяет слать меньше запросов на сервер.


Начать знакомство с протоколом имеет смысл с официального сайта, затем полезно почитать сайты коммерческих поставщиков облачных решений по аутентификации вроде Connect2id, Auth0 и Stormpath. Описание всех нужных терминов не привожу, во-первых это была бы стена текста, а во вторых всё необходимое есть по этим ссылкам.


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


Что мы хотим получить в итоге


Мы реализуем OpenId Connect Implicit Flow, который рекомендован для JavaScript-приложений, в браузере, в том числе для SPA. В процессе мы чуть глубже, чем это обычно делается в пошаговых руководствах, обсудим разные значимые настройки. Затем мы посмотрим, как работает наша реализация с точки зрения протокола OpenId Connect, а также изучим, как имплементация соотносится с протоколом.


Инструменты


  • На стороне сервера воспользуемся IdentityServer4
  • На стороне клиента будем использовать библиотеку oidc-client

Основные авторы обеих библиотек — Брок Аллен и Доминик Брайер.


Сценарии взаимодействия


У нас будет 3 проекта:


  1. IdentityServer — наш сервер аутентификации OpenId Connect.
  2. Api — наш тестовый веб-сервис.
  3. Client — наше клиентское приложение на JavaScript, основано на коде JavaScriptClient.

Сценарий взаимодействия таков: клиентское приложение Client авторизуется при помощи сервера аутентификации IdentityServer и получает access_token (JWT), который затем использует в качестве Bearer-токена для вызова веб-сервиса на сервере Api.


Стандарт OpenId Connect описывает разные варианты порядка прохождения аутентификации. Эти варианты на языке стандарта называются Flow.
Implicit Flow, который мы рассматриваем в этой статье, включает такие шаги:


  1. Клиент готовит запрос на аутентификацию, содержащий нужные параметры запроса.
  2. Клиент шлёт запрос на аутентификацию на сервер авторизации.
  3. Сервер авторизации аутентифицирует конечного пользователя.
  4. Сервер авторизации получает подтверждение от конечного пользователя.
  5. Сервер авторизации посылает конечного пользователя обратно на клиент с id_token'ом и, если требуется, access_token'ом.
  6. Клиент валидирует id_token и получает Subject Identifier конечного пользователя.

Implicit Flow


Имплементация


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


Запускать Api и IdentityServer в процессе выполнения этого упражнения рекомендую через dotnet runIdentityServer пишет массу полезной диагностической информации в процессе своей работы, данная информация сразу будет видна в консоли.


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


Давайте приступим к реализации. Для определённости будем предполагать, что Вы используете Visual Studio 2017 (15.3). Готовый код решения можно посмотреть здесь
Создайте пустой solution OpenIdConnectSample.


Большая часть кода основана на примерах из документации IdentityServer, однако код в данной статье дополнен тем, чего, на мой взгляд, не хватает в официальной документации, и аннотирован.


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


1. IdentityServer


Создайте solution с пустым проектом, в качестве платформы выберите ASP.NET Core 1.1.


Установите такие NuGet-пакеты


Install-Package Microsoft.AspNetCore.Mvc -Version 1.1.3
Install-Package Microsoft.AspNetCore.StaticFiles -Version 1.1.2
Install-Package IdentityServer4 -Version 1.5.2

Версии пакетов здесь значимы, т.к. Install-Package по умолчанию устанавливает последние версии. Хотя авторы уже сделали порт IdentityServer на Asp.NET Core 2.0 в dev-ветке, на момент написания статьи, они ещё не портировали Quickstart UI. Различия в коде нашего примера для .NET Core 1.1 и 2.0 невелики.


Измените метод Main Program.cs так, чтобы он выглядел следующим образом


public static void Main(string[] args)
{
    Console.Title = "IdentityServer";

    // https://docs.microsoft.com/en-us/aspnet/core/fundamentals/servers/kestrel?tabs=aspnetcore2x
    var host = new WebHostBuilder()
        .UseKestrel()
        // задаём порт, и адрес на котором Kestrel будет слушать
        .UseUrls("http://localhost:5000")
        // имеет значения для UI логина-логаута 
        .UseContentRoot(Directory.GetCurrentDirectory())
        .UseIISIntegration()
        .UseStartup<Startup>()
        .Build();

    host.Run();
}

Затем в Startup.cs


  1. Добавьте пространства имён
    using System.Security.Claims;
    using IdentityServer4;
    using IdentityServer4.Configuration;
    using IdentityServer4.Models;
    using IdentityServer4.Test;
  2. Добавьте несколько вспомогательных методов, которые содержат настройки IdentityServer, обратите внимание на комментарии. Эти методы будут в дальнейшем вызваны в ConfigureServices. Рекомендую читать текст методов перед их добавлением в проект — с одной стороны это позволит сразу иметь целостную картину происходящего, с другой стороны лишнего там мало.

Настройки информации для клиентских приложений


public static IEnumerable<IdentityResource> GetIdentityResources()
{
    // определяет, какие scopes будут доступны IdentityServer
    return new List<IdentityResource>
    {
        // "sub" claim
        new IdentityResources.OpenId(),
        // стандартные claims в соответствии с profile scope
        // http://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims
        new IdentityResources.Profile(),
    };
}

Эти настройки добавляют поддержку claim sub, это минимальное требование для соответствия нашего токена OpenId Connect, а также claim scope profile, включающего описанные стандартом OpenId Connect поля профиля типа имени, пола, даты рождения и подобных.


Это аналогичные предыдущим настройки, но информация предназначается для API


public static IEnumerable<ApiResource> GetApiResources()
{
    // claims этих scopes будут включены в access_token
    return new List<ApiResource>
    {
        // определяем scope "api1" для IdentityServer
        new ApiResource("api1", "API 1", 
            // эти claims войдут в scope api1
            new[] {"name", "role" })
    };
}     

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


public static IEnumerable<Client> GetClients()
{
    return new List<Client>
    {
        new Client
        {
            // обязательный параметр, при помощи client_id сервер различает клиентские приложения 
            ClientId = "js",
            ClientName = "JavaScript Client",
            AllowedGrantTypes = GrantTypes.Implicit,
            AllowAccessTokensViaBrowser = true,
            // от этой настройки зависит размер токена, 
            // при false можно получить недостающую информацию через UserInfo endpoint
            AlwaysIncludeUserClaimsInIdToken = true,
            // белый список адресов на который клиентское приложение может попросить
            // перенаправить User Agent, важно для безопасности
            RedirectUris = {
                // адрес перенаправления после логина
                "http://localhost:5003/callback.html",
                // адрес перенаправления при автоматическом обновлении access_token через iframe
                "http://localhost:5003/callback-silent.html"
            },
            PostLogoutRedirectUris = { "http://localhost:5003/index.html" },
            // адрес клиентского приложения, просим сервер возвращать нужные CORS-заголовки
            AllowedCorsOrigins = { "http://localhost:5003" },
            // список scopes, разрешённых именно для данного клиентского приложения
            AllowedScopes =
            {
                IdentityServerConstants.StandardScopes.OpenId,
                IdentityServerConstants.StandardScopes.Profile,
                "api1"
            },

            AccessTokenLifetime = 3600, // секунд, это значение по умолчанию
            IdentityTokenLifetime = 300, // секунд, это значение по умолчанию

            // разрешено ли получение refresh-токенов через указание scope offline_access
            AllowOfflineAccess = false,
        }
    };
}

Тестовые пользователи, обратите внимание, что bob у нас админ


public static List<TestUser> GetUsers()
{
    return new List<TestUser>
    {
        new TestUser
        {
            SubjectId = "1",
            Username = "alice",
            Password = "password",

            Claims = new List<Claim>
            {
                new Claim("name", "Alice"),
                new Claim("website", "https://alice.com"),
                new Claim("role", "user"),
            }
        },
        new TestUser
        {
            SubjectId = "2",
            Username = "bob",
            Password = "password",

            Claims = new List<Claim>
            {
                new Claim("name", "Bob"),
                new Claim("website", "https://bob.com"),
                new Claim("role", "admin"),
            }
        }
    };
}

  1. Измените метод ConfigureServices так

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc();

    services.AddIdentityServer(options =>
    {
        // http://docs.identityserver.io/en/release/reference/options.html#refoptions
        options.Endpoints = new EndpointsOptions
        {
            // в Implicit Flow используется для получения токенов
            EnableAuthorizeEndpoint = true,
            // для получения статуса сессии
            EnableCheckSessionEndpoint = true,
            // для логаута по инициативе пользователя
            EnableEndSessionEndpoint = true,
            // для получения claims аутентифицированного пользователя 
            // http://openid.net/specs/openid-connect-core-1_0.html#UserInfo
            EnableUserInfoEndpoint = true,
            // используется OpenId Connect для получения метаданных
            EnableDiscoveryEndpoint = true,

            // для получения информации о токенах, мы не используем
            EnableIntrospectionEndpoint = false,
            // нам не нужен т.к. в Implicit Flow access_token получают через authorization_endpoint
            EnableTokenEndpoint = false,
            // мы не используем refresh и reference tokens 
            // http://docs.identityserver.io/en/release/topics/reference_tokens.html
            EnableTokenRevocationEndpoint = false
        };

        // IdentitySever использует cookie для хранения своей сессии
        options.Authentication = new IdentityServer4.Configuration.AuthenticationOptions
        {
            CookieLifetime = TimeSpan.FromDays(1)
        };

    })
        // тестовый x509-сертификат, IdentityServer использует RS256 для подписи JWT
        .AddDeveloperSigningCredential()
        // что включать в id_token
        .AddInMemoryIdentityResources(GetIdentityResources())
        // что включать в access_token
        .AddInMemoryApiResources(GetApiResources())
        // настройки клиентских приложений
        .AddInMemoryClients(GetClients())
        // тестовые пользователи
        .AddTestUsers(GetUsers());
}

В этом методе мы указываем настройки IdentityServer, в частности сертификаты, используемые для подписывания токенов, настройки scope в смысле OpenId Connect и OAuth2.0, настройки приложений-клиентов, а также настройки пользователей.


Теперь чуть подробнее. AddIdentityServer регистрирует сервис IdentityServer в механизме разрешения зависимостей ASP.NET Core, это нужно сделать, чтобы была возможность добавить его как middleware в Configure.


  • IdentityServer подписывает токены при помощи RSA SHA 256, поэтому требуется пара приватный-публичный ключ. AddDeveloperSigningCredential добавляет тестовые ключи для подписи JWT-токенов, а именно id_token, access_token в нашем случае. В продакшне нужно заменить эти ключи, сделать это можно, например сгенерировав самоподписной сертификат.
  • AddInMemoryIdentityResources. Почитать о том, что понимается под ресурсами можно тут, а зачем они нужны — тут.

Метод Configure должен выглядеть так



public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
    loggerFactory.AddConsole(LogLevel.Debug);
    app.UseDeveloperExceptionPage();

    // подключаем middleware IdentityServer
    app.UseIdentityServer();

    // эти 2 строчки нужны, чтобы нормально обрабатывались страницы логина
    app.UseStaticFiles();
    app.UseMvcWithDefaultRoute();
}

Скачайте из официального репозитория Starter UI для IdentityServer, затем скопируйте файлы в папку проекта, так чтобы папки совпали по структуре, например wwwroot с wwwroot.


Проверьте, что проект компилируется.


2. Api


Данный проект — игрушечный сервер API с ограниченным доступом.


Добавьте в solution ещё один пустой проект Api, в качестве платформы выберите ASP.NET Core 1.1. Т.к. мы не собираемся создавать полноценное веб-приложение в данном проекте, а лишь легковесный веб-сервис, отдающий JSON, ограничимся лишь MvcCore middleware вместо полного Mvc.


Добавьте нужные пакеты, выполнив эти команды в Package Manager Console


Install-Package Microsoft.AspNetCore.Mvc.Core -Version 1.1.3
Install-Package Microsoft.AspNetCore.Mvc.Formatters.Json -Version 1.1.3
Install-Package Microsoft.AspNetCore.Cors -Version 1.1.2
Install-Package IdentityServer4.AccessTokenValidation -Version 1.2.1

Начнём с того, что добавим нужные настройки Kestrel в Program.cs


public static void Main(string[] args)
{
    Console.Title = "API";

    var host = new WebHostBuilder()
        .UseKestrel()
        .UseUrls("http://localhost:5001")
        .UseContentRoot(Directory.GetCurrentDirectory())
        .UseIISIntegration()
        .UseStartup<Startup>()
        .Build();

    host.Run();
}

В Startup.cs потребуется несколько меньше изменений.
Для ConfigureServices


public void ConfigureServices(IServiceCollection services)
{
    services.AddCors(options=>
    {
        // задаём политику CORS, чтобы наше клиентское приложение могло отправить запрос на сервер API
        options.AddPolicy("default", policy =>
        {
            policy.WithOrigins("http://localhost:5003")
                .AllowAnyHeader()
                .AllowAnyMethod();
        });
    });

    // облегчённая версия MVC Core без движка Razor, DataAnnotations и подобного, сопоставима с Asp.NET 4.5 WebApi
    services.AddMvcCore()
        // добавляем авторизацию, благодаря этому будут работать атрибуты Authorize
        .AddAuthorization(options =>
            // политики позволяют не работать с Roles magic strings, содержащими перечисления ролей через запятую
            options.AddPolicy("AdminsOnly", policyUser =>
            {
                policyUser.RequireClaim("role", "admin");
            })
        )
        // добавляется AddMVC, не добавляется AddMvcCore, мы же хотим получать результат в JSON 
        .AddJsonFormatters();

}

А вот так должен выглядеть Configure


public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
    loggerFactory.AddConsole(LogLevel.Debug);

    // добавляем middleware для CORS 
    app.UseCors("default");

    // добавляем middleware для заполнения объекта пользователя из OpenId  Connect JWT-токенов
    app.UseIdentityServerAuthentication(new IdentityServerAuthenticationOptions
    {
        // наш IdentityServer
        Authority = "http://localhost:5000",
        // говорим, что нам не требуется HTTPS при общении с IdentityServer, должно быть true на продуктиве
        // https://docs.microsoft.com/en-us/aspnet/core/api/microsoft.aspnetcore.builder.openidconnectoptions
        RequireHttpsMetadata = false,

        // это значение будет сравниваться со значением поля aud внутри access_token JWT
        ApiName = "api1",

        // можно так написать, если мы хотим разделить наш api на отдельные scopes и всё же сохранить валидацию scope
        // AllowedScopes = { "api1.read", "api1.write" }

        // читать JWT-токен и добавлять claims оттуда в HttpContext.User даже если не используется атрибут Authorize со схемоЙ, соответствующей токену
        AutomaticAuthenticate = true,
        // назначаем этот middleware как используемый для формирования authentication challenge
        AutomaticChallenge = true,

        // требуется для [Authorize], для IdentityServerAuthenticationOptions - значение по умолчанию
        RoleClaimType = "role", 
    });

    app.UseMvc();
}

Осталось добавить наш контроллер, он возвращает текущие Claims пользователя, что удобно для того, чтобы понимать, как middleware аутентификации IdentityServer расшифровал access_token.
Добавьте в проект единственный контроллер IdentityController.
Cодержимое файла должно быть таким.


using System.Linq;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;

namespace Api.Controllers
{

    [Authorize]
    public class IdentityController : ControllerBase
    {
        [HttpGet]
        [Route("identity")]
        public IActionResult Get()
        {
            return new JsonResult(from c in User.Claims select new { c.Type, c.Value });
        }

        [HttpGet]
        [Route("superpowers")]
        [Authorize(Policy = "AdminsOnly")]
        public IActionResult Superpowers()
        {
            return new JsonResult("Superpowers!");
        }
    }
}

Убедитесь, что проект компилируется.


3. Client


Этот проект фактически не содержит значимой серверной части. Весь серверный код — это просто настройки веб-сервер Kestrel, с тем чтобы он отдавал статические файлы клиента.


Так же, как и прошлых 2 раза добавьте в решение пустой проект, назовите его Client.


Установите пакет для работы со статическими файлами.


Install-Package Microsoft.AspNetCore.StaticFiles -Version 1.1.2

Измените файл Program.cs


public static void Main(string[] args)
{
    var host = new WebHostBuilder()
        .UseKestrel()
        .UseUrls("http://localhost:5003")
        .UseContentRoot(Directory.GetCurrentDirectory())
        .UseIISIntegration()
        .UseStartup<Startup>()
        .Build();

    host.Run();
}

Класс Startup должен содержать такой код.


public void ConfigureServices(IServiceCollection services)
{
}
public void Configure(IApplicationBuilder app)
{
    app.UseDefaultFiles();
    app.UseStaticFiles();
}

Клиентский код на JavaScript, с другой стороны, и содержит всю логику аутентификации и вызовов Api.


Мы по одному добавим в папку wwwroot проекта следующие файлы.


  • index.html — простой HTML-файл с кнопками различных действий и ссылкой на JavaScript-файл приложения app.js и oidc-client.js.
  • oidc-client.js — клиентская библиотека, реализующая OpenId Connect
  • app.js — настройки oidc-client и обработчики событий кнопок
  • callback.html — страница, на которую сервер аутентификации перенаправляет клиентское приложение, передавая параметры, необходимые для завершения процедуры входа.
  • callback-silent.html — страница, аналогичная callback.html, однако именно для случая, когда происходит "фоновый" повторный логин через iframe. Это нужно чтобы продлевать доступ пользователя к ресурсам без использования refresh_token.

index.html
Добавьте новый HTML-файл с таким названием в папку wwwroot проекта.


<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title></title>
</head>
<body>
    <button id="login">Login</button>
    <button id="getUser">Get User</button>
    <button id="getSuperpowers">Get Superpowers!</button>
    <button id="api">Call API</button>
    <button id="logout">Logout</button>

    <pre id="results"></pre>

    <script src="oidc-client.js"></script>
    <script src="app.js"></script>
</body>
</html>

oidc-client.js
Скачайте этот файл отсюда (1.3.0) и добавьте в проект.


app.js
Добавьте новый JavaScript-файл с таким названием в папку wwwroot проекта.


Добавьте


/// <reference path="oidc-client.js" />

в начале файла для поддержки IntelliSense.


Вставьте этот код к началу app.js


Oidc.Log.logger = console;
Oidc.Log.level = 4;

Первой строкой, пользуясь совместимостью по вызываемым методам, устанавливаем стандартную консоль браузера в качестве стандартного логгера для oidc-client. Второй строкой просим выводить все сообщения. Это позволит увидеть больше подробностей, когда мы перейдём ко второй части статьи, и будем смотреть, как же наша имплементация работает.


Теперь давайте по частям добавим остальной код в этот файл.


Эта часть кода самая длинная, и, пожалуй, самая интересная. Она содержит настройки библиотеки основного объекта UserManager библиотеки oidc-client, а также его создание. Рекомендую ознакомиться с самими настройками и комментариями к ним.


var config = {

    authority: "http://localhost:5000", // Адрес нашего IdentityServer
    client_id: "js", // должен совпадать с указанным на IdentityServer
    // Адрес страницы, на которую будет перенаправлен браузер после прохождения пользователем аутентификации
    // и получения от пользователя подтверждений - в соответствии с требованиями OpenId Connect
    redirect_uri: "http://localhost:5003/callback.html",
    // Response Type определяет набор токенов, получаемых от Authorization Endpoint
    // Данное сочетание означает, что мы используем Implicit Flow
    // http://openid.net/specs/openid-connect-core-1_0.html#Authentication
    response_type: "id_token token",
    // Получить subject id пользователя, а также поля профиля в id_token, а также получить access_token для доступа к api1 (см. наcтройки IdentityServer)
    scope: "openid profile api1",
    // Страница, на которую нужно перенаправить пользователя в случае инициированного им логаута
    post_logout_redirect_uri: "http://localhost:5003/index.html",
    // следить за состоянием сессии на IdentityServer, по умолчанию true
    monitorSession: true,
    // интервал в миллисекундах, раз в который нужно проверять сессию пользователя, по умолчанию 2000
    checkSessionInterval: 30000,
    // отзывает access_token в соответствии со стандартом https://tools.ietf.org/html/rfc7009
    revokeAccessTokenOnSignout: true,
    // допустимая погрешность часов на клиенте и серверах, нужна для валидации токенов, по умолчанию 300
    // https://github.com/IdentityModel/oidc-client-js/blob/1.3.0/src/JoseUtil.js#L95
    clockSkew: 300,
    // делать ли запрос к UserInfo endpoint для того, чтоб добавить данные в профиль пользователя
    loadUserInfo: true,
};
var mgr = new Oidc.UserManager(config);

Давайте теперь добавим обработчики для кнопок и подписку на них.


function login() {
    // Инициировать логин
    mgr.signinRedirect();
}

function displayUser() {
    mgr.getUser().then(function (user) {
        if (user) {
            log("User logged in", user.profile);
        }
        else {
            log("User not logged in");
        }
    });
}

function api() {
    // возвращает все claims пользователя
    requestUrl(mgr, "http://localhost:5001/identity");
}

function getSuperpowers() {
    // этот endpoint доступен только админам
    requestUrl(mgr, "http://localhost:5001/superpowers");
}

function logout() {
    // Инициировать логаут
    mgr.signoutRedirect();
}

document.getElementById("login").addEventListener("click", login, false);
document.getElementById("api").addEventListener("click", api, false);
document.getElementById("getSuperpowers").addEventListener("click", getSuperpowers, false);
document.getElementById("logout").addEventListener("click", logout, false);
document.getElementById("getUser").addEventListener("click", displayUser, false);

// отобразить данные о пользователе после загрузки
displayUser();

Осталось добавить пару утилит


function requestUrl(mgr, url) {
    mgr.getUser().then(function (user) {
        var xhr = new XMLHttpRequest();
        xhr.open("GET", url);
        xhr.onload = function () {
            log(xhr.status, 200 == xhr.status ? JSON.parse(xhr.responseText) : "An error has occured.");
        }
        // добавляем заголовок Authorization с access_token в качестве Bearer - токена. 
        xhr.setRequestHeader("Authorization", "Bearer " + user.access_token);
        xhr.send();
    });
}

function log() {
    document.getElementById('results').innerText = '';

    Array.prototype.forEach.call(arguments, function (msg) {
        if (msg instanceof Error) {
            msg = "Error: " + msg.message;
        }
        else if (typeof msg !== 'string') {
            msg = JSON.stringify(msg, null, 2);
        }
        document.getElementById('results').innerHTML += msg + '\r\n';
    });
}

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


callback.html


<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title></title>
</head>
<body>
    <script src="oidc-client.js"></script>
    <script>
        new Oidc.UserManager().signinRedirectCallback().then(function () {
            window.location = "index.html";
        }).catch(function (e) {
            console.error(e);
        });
    </script>
</body>
</html>

callback-silent.html


<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title></title>
</head>
<body>
    <script src='oidc-client.js'></script>
    <script>
        new Oidc.UserManager().signinSilentCallback();
    </script>
</body>
</html>

Готово!


Как это работает


Запускать проекты рекомендую так: запускаете консоль, переходите в папку проекта, выполняете команду dotnet run. Это позволит видеть что IdentityServer и другие приложения логируют в консоль.


Запустите вначале IdentityServer и Api, а затем и Client.


Откройте страницу http://localhost:5003/index.html Client.
На этом этапе Вы можете захотеть очистить консоль при помощи clear().


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



Во вкладке Network инструментов разработчика Вы можете захотеть поставить галочку напротив Preserve log чтобы редиректы не мешали в дальнейшем проверять значения различных параметров.


Обновите страницу при помощи CTRL+F5.


Happy path


Посмотрим, какие действия соответствуют первым двум шагам спецификации.
1. Клиент готовит запрос на аутентификацию, содержащий нужные параметры запроса.
2. Клиент шлёт запрос на аутентификацию на сервер авторизации.
Кликните на кнопку Login.


Взаимодействие с сервером авторизации начинается с GET-запроса на адрес
http://localhost:5000/.well-known/openid-configuration
Этим запросом oidc-client получает метаданные нашего провайдера OpenId Connect (рекомендую открыть этот адрес в другой вкладке), в том числе authorization_endpoint
http://localhost:5000/connect/authorize


Обратите внимание, что для хранения данных о пользователе используется WebStorage. oidc-client позволяет указать, какой именно объект будет использоваться, по умолчанию это sessionStorage.


В этот момент будет послан запрос на аутентификацию на authorization_endpoint с такими параметрами строки запроса


Имя Значение
client_id js
redirect_uri http://localhost:5003/callback.html
response_type id_token token
scope openid profile api1
state некоторое труднопредсказуемое значение
nonce некоторое труднопредсказуемое значение

Обратите внимание, что redirect_uri соответствует адресу, который мы указали для нашего клиента с client_id js в настройках IdentityServer.


Т.к. пользователь ещё не аутентифицирован, IdentityServer вышлет в качестве ответа редирект на форму логина.


Затем браузер перенаправлен на http://localhost:5000/account/login.


3. Сервер авторизации аутентифицирует конечного пользователя.
4. Сервер авторизации получает подтверждение от конечного пользователя.
5. Сервер авторизации посылает конечного пользователя обратно на клиент с id token'ом и, если требуется, access token'ом.


Вводим bob в качестве логина и password в качестве пароля, отправляем форму.
Нас вначале вновь перенаправляют на authorization_endpoint, а оттуда на страницу подтверждения в соответствии с OpenId Connect разрешения получения relying party (в данном случае нашим js-клиентом) доступа к различным scopes.


Со всем соглашаемся, отправляем форму. Аналогично форме аутентификации, в ответ на отправку формы нас перенаправляют на authorization_endpoint, данные на authorization_endpoint передаются при помощи cookie.


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


При использовании Implicit Flow параметры передаются после #. Это нужно для того, чтобы эти значения были доступны нашему приложению на JavaScript, но при этом не отправлялись на веб-сервер.


Имя Значение
id_token Токен с данными о пользователе для клиента
access_token Токен с нужными данными для доступа к API
token_type Тип access_token, в нашем случае Bearer
expires_in Время действия access_token
scope scopes на которые пользователь дал разрешение через пробел

6. Клиент валидирует id token и получает Subject Identifier конечного пользователя.


oidc-client проверяет вначале наличие сохранённого на клиенте state, затем сверяет nonce с полученным из id_token. Если всё сходится, происходит проверка самих токенов на валидность (например, проверяется подпись и наличие sub claim в id_token). На этом этапе происходит чтение чтение содержимого id_token о объект профиля пользователя библиотеки oidc-client на стороне клиента.


Если Вы захотите расшифровать id_token (проще всего его скопировать из вкладки Network инструментов разработчика), то увидите, что payload содержит что-то подобное


{
  "nbf": 1505143180,
  "exp": 1505146780,
  "iss": "http://localhost:5000",
  "aud": "js",
  "nonce": "2bd3ed0b260e407e8edd0d03a32f150c",
  "iat": 1505143180,
  "at_hash": "UAeZEg7xr23ToH2R2aUGOA",
  "sid": "053b5d83fd8d3ce3b13d3b175d5317f2",
  "sub": "2",
  "auth_time": 1505143180,
  "idp": "local",
  "name": "Bob",
  "website": "https://bob.com",
  "amr": [
    "pwd"
  ]
}

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


Для access_token в нашем случае payload будет выглядеть, в том числе в соответствии с настройками, чуть иначе.


{
  "nbf": 1505143180,
  "exp": 1505143480,
  "iss": "http://localhost:5000",
  "aud": [
    "http://localhost:5000/resources",
    "api1"
  ],
  "client_id": "js",
  "sub": "2",
  "auth_time": 1505143180,
  "idp": "local",
  "name": "Bob",
  "role": "admin",
  "scope": [
    "openid",
    "profile",
    "api1"
  ],
  "amr": [
    "pwd"
  ]
}

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


В случае когда проверка завершается успехом, происходит чтение claims из id_token в объект профиля на стороне клиента.


Затем, но только если указана настройка loadUserInfo, происходит обращение к UserInfo Endpoint. При этом при обращении UserInfo Endpoint для получения claims профиля пользователя в заголовке Authorization в качестве Bearer-токена используется access_token, а полученные claims будут добавлены в JavaScript-объект профиля на стороне клиента.


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


Вызываем метод API


Нажмите кнопку "Call API".
Произойдёт ajax-запрос на адрес http://localhost:5001/identity.
А именно, вначале будет OPTIONS-запрос согласно требованиями CORS т.к. мы осуществляем запрос ресурса с другого домена и используем заголовки, не входящие в список "безопасных" (Authorization, например).


Затем будет отправлен, собственно, сам GET-запрос. Обратите внимание, что в заголовке запроса Authorization будет указано значение Bearer <значение access_token>.


IdentityServer middleware на стороне сервера проверит токен. Внутри кода IdentityServer middleware проверка токенов фактически осуществляется стандартным Asp.Net Core JwtBearerMiddleware.


Пользователь будет считаться авторизованным, поэтому сервер вернёт нам ответ с кодом 200.


Logout


Отправляется GET-запрос на end_session_endpoint


Имя Значение
id_token_hint Содержит значение id_token
post_logout_redirect_uri URI, на который клиент хочет, чтобы провайдер аутентификации

В ответ нас перенаправляют на страницу, содержащую данные о логауте для пользователя.


Проверяем работу ролей


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


Попробуйте зайти вначале под пользователем alice и нажать кнопку Get Superpowers!, затем зайдите под пользователем bob и проделайте то же самое.


Другие варианты развития событий


Пользователь жмёт do not allow


Нажмите Logout и залогиньтесь ещё раз, на этот раз используйте данные
Username: alice
Password: password


На странице подтверждения http://localhost:5000/consent нажмите No, Do Not Allow.


Вы попадёте на страницу завершения логина клиентского приложения http://localhost:5003/callback.html.
По причине того, что страница подтверждения пользователем передаёт фрагмент URL #error=access_denied, выполнение signinRedirectCallback пойдёт по другому пути, и промис в результате будет иметь статус rejected.


На странице callback.html будет для промиса выполнен catch-обработчик, он выведет текст ошибки в консоль.


Пользователь не даёт разрешения на профиль


Скопируйте закодированный id_token из одноимённого параметра URL ответа и убедитесь, что теперь в него не входят claims, которые входят в стандартный scope profile.


Claims, которые входят в стандартный scope profile можно посмотреть тут.


При этом вызвать API получится.


Пользователь на даёт разрешение на api1


В токене теперь нет claim api1


"scope": [
    "openid",
    "profile"
],

При попытке вызвать Api нам теперь возвращают 401 (Unathorized).


access_token устаревает


Дождитесь устаревания access_token, нажмите кнопку Call API.


API будет вызван! Это вызвано тем, что IdentityServer использует middleware Asp.Net Core, который использует понятие ClockSkew. Это нужно для того, чтобы всё в целом работало в случае если часы на клиенте и разных серверах несколько неточны, например, не возникали ситуации вроде токена, который был выпущен на период целиком в будущем. Значение ClockSkew по умолчанию 5 минут.


Теперь подождите 5 минут и убедитесь, что вызов API теперь возвращает 401 (Unathorized).


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


access_token обновляется


Давайте теперь добавим в app.js в объект config код, так чтобы получилось


var config = {
    // ...
    // если true, клиент попытается обновить access_token перед его истечением, по умолчанию false
    automaticSilentRenew: true,
    // эта страница используется для "фонового" обновления токена пользователя через iframe
    silent_redirect_uri: 'http://localhost:5003/callback-silent.html',
    // за столько секунд до истечения oidc-client постарается обновить access_token
    accessTokenExpiringNotificationTime: 60,
    // ...
} 

При помощи консоли браузера убедитесь что теперь происходит автоматическое обновление access_token. Нажмите кнопку Call API чтобы убедиться, что всё работает.


id_token устаревает


Если access_token предназначается для ресурса API и ресурс обязан проверить его валидность, в том числе не устарел ли токен, при обращении к нему, то id_token предназначен именно для самого клиентского приложения. Поэтому и проверка должна проводиться на клиенте js-клиенте. Хорошо описано тут.


Заключение


Если Вы следовали инструкциям, на данный момент Вы:


  1. Своими руками сделали рабочую реализацию OpenId Connect Implicit Flow при помощи IdentityServer и oidc-client на платформе ASP.NET Core 1.1.
  2. Ознакомились с различными параметрами, позволяющими настроить части имплементации для Ваших нужд.
  3. И, главное, несколько подразобрались, как имплементация соотносится со стандартом, причём до того, как выучили стандарт наизусть.

Полезные ссылки


  1. Хороший туториал.
  2. Официальные примеры IdentityServer4
  3. Официальные примеры oidc-client.
  4. Тут можно почитать про политики авторизации в ASP.NET Core. Заодно стоит прочитать и это.
  5. В этой статье описано как использовать атрибут Authorize со списками ролей совместно с IdentityServer.
  6. Здесь описано почему в стандарте OpenId Connect 2 токена — id_token и access_token вместо одного.
  7. В процессе подготовки этой статьи вышла эта статья по реализации OpenId Connect в ASP.NET Core.
Tags:
Hubs:
+9
Comments 39
Comments Comments 39

Articles