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


Недавно мне потребовалось разобраться, как делается аутентификация на 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 = 300, // секунд, это значение по умолчанию
            IdentityTokenLifetime = 3600, // секунд, это значение по умолчанию

            // разрешено ли получение 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.
Поделиться публикацией
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама
Комментарии 37
  • 0
    К слову, уже есть rc версия под .NET Core 2.0, включая пакет для работы с EF Core (тоже 2.0). Только валидацию токенов на стороне API ещё не завезли, так что OpenIdConnect и JWT-only (их middlewar'а даёт интроспекцию и возможность юзать Reference-токены)
    • 0

      Спасибо за замечание. Статья писалась в свободное от прочей работы время, затем некоторое время болталась на модерации, я не заметил, что Брок запушил апдейт Quickstart UI в сентябре.

    • +1
      А чем это все лучше стандартной авторизации кукой, которая по сути готова изкоробки?
      Понимаю, что даром бы вот это все не городили, но все же? Централизированная аутентификация для разных продуктов, большая секурность (на основе чего?)?
      • +1

        Я бы не сказал, что токены "лучше" или "хуже", у них есть свои области применимости, как и у кук. Например, HTTP-only Secure куки признаны одним из лучших механизмов хранения сессии. IdentityServer4 хранит сессию пользователя именно в куке по умолчанию.
        Для того, чтобы толково ответить на Ваш вопрос понадобится написать немаленькую такую статью, не думаю, что это хорошая идея, тем более, что многие это уже сделали.
        Вот тут, если пролистаете до реального описания кук и токенов, может быть интересно почитать, например.


        Если совсем коротко, то мне на ум приходят такие варианты.


        1. Требуется выдавать "токены" одним центром авторизации для многих ресурсов. Возможно, это даже не ваш центр авторизации (Google, Facebook, Auth0 и т.д.).
        2. Требуется логика, которая сильно выигрывает от stateless-механизма. Как тут в примере про отель.
        3. Проблемой для реализуемого сценария является сам протокол кук, и связанная с ним специфика вроде отправки кук при каждом GET-запросе по умолчанию вообще, и CSRF-уязвимости в частности.
        • 0

          А как насчет такой вот статьи Stop using JWT for sessions и ее продолжения?

          • 0

            Ну во-первых, ни моя статья, ни мои комментарии не содержат предложений использовать JWT для сессий.


            Во вторых, статью я читал, она ИМХО построена по старому-доброму принципу "вначале хорошенько передёрнуть, потом всё эмоционально развенчивать". Нормальное обсуждение статьи тут. Если любите такой жанр, можете почитать ещё эту.

            • 0
              Да я ни в коем случае не критикую Вашу статью. Мне просто показалась интересной диаграмма «почему ваши решения не работают» из второй части. Получается мы тут все городим-городим, а старые добрые сессии оказываются не хуже.
              • 0

                Правильно говорить не "старые добрые сессии оказываются не хуже", а "старые добрые куки оказываются не хуже для реализации сессий".


                Если задача заключается не в реализации сессий, а в защите доступа к API — то токены справляются даже лучше кук, потому что их не надо дополнительно защищать от CSRF-атаки.

                • 0
                  Но надо защищать от кражи из LocalStorage сторонними скриптами, подключенными на странице. И мне что-то не приходит в голову, как.

                  А еще, основная фишка токенов – stateless аутентификация. Но она получается не на столько секьюрной как stateful с помощью сессий.
                  • +1

                    Куки тоже от сторонних скриптов не спасают. Да, нельзя украсть http only куку — но никто не мешает прямо со страницы делать запросы. Иными словами, успешный XSS проламывает любую защиту.


                    Если же хочется защититься именно от кражи для повышения порога атаки — токен можно привязать к Origin.

                    • 0
                      А как привязать токен к origin? Идея действительно интересная.
                      Кто помешает использовать украденный токен потом вообще из curl?
                      • 0

                        Никто не помешает, что-то я забыл про эту утилиту и глупость сморозил.

                    • 0

                      Да, про stateless аутентификацию. Она делается не с любыми токенами, а только с подписанными (например, с JWT). Но не надо думать, что только токены можно подписывать — с куками такое тоже можно провернуть. И недостатки у них будут общие — отсутствие возможности логаута.

                      • 0
                        Да я вообще не противопоставлять куки и токены. JWT можно и через куки слать.
                        А подписанная кука уже сама по себе токен. Я в основном про stateful/stateless.
                    • 0
                      Вот не в первый раз встречаю утверждение, что приложения на токенах не нужно защищать от CSRF.

                      Тут внезапно не всё так очевидно. Вообще говоря, приложения, которые получают доступ к ресурсу на основе Bearer-токенов обычно защищённее, т.к. атакующий не может полагаться на стандарт раз, и access_token'ы обычно делают живущими недолго два. Однако, если у вас SPA с восстановлением текущего состояния из URL, при атаке через CSRF может так случится, что приложение прекрасно восстановит контекст из URL и localStorage, а затем сделает нужный хакеру запрос.

                      Так что такие приложения, вообще говоря, труднее атаковать — да, но защищать именно от CSRF-атак их тоже нужно.
                      • 0

                        Это вы уже описали атаку на приложение, а не на API. Соответственно, и защищать от такой атаки нужно само приложение.


                        CSRF тут, кстати, все равно не нужен. Надо лишь продолжать соблюдать нечто вроде принципа разделения GET и POST — переход по внутреннему роуту не должен вызывать никаких запросов на изменение, все запросы на изменение должны генерироваться только событиями. И не забыть запретить запуск во фрейме.

                        • 0
                          Это вы уже описали атаку на приложение, а не на API. Соответственно, и защищать от такой атаки нужно само приложение.

                          Есть такое.

                          По остальному согласен. Ну и anti-CSRF-токены различные.
                          • 0

                            А вот anti-CSRF-токены как раз тут и не помогут. Потому что чтобы такой токен работал — его надо будет запихнуть в строку адреса, но это убьет саму идею адресации: такой защищенный токеном адрес нельзя будет ни другу передать, ни в закладки добавить. Проще сразу делать SPA без роутинга и игр с URL.

                            • 0
                              Ну например на форме скрытое поле с anti-CSRF-токеном, выданным сервером. И делать shareable полузаполненную форму смысла большого нет, и проблема с CSRF постинга форм решена.
                              • 0

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

                                • 0

                                  Но постить-то заполненную сможет, при помощи специальных утилит, например. Или вот заходит пользователь на какой-то сайт, а там появляется popup с формой, и форма отправляется, за долю секунды. Или даже такое.

                                  • 0

                                    Я уже писал, от XSS не спасает ничего.

                                    • 0
                                      Я привёл примеры именно способов осуществления CSRF-атаки, у самого приложения собственных XSS-уязвимостей может и не быть. А для CSRF javascript и social engineering применять не запрещено.
                                      • 0

                                        Повторяю: антиCSRF-токен не защищает от Self-XSS атаки.

                                        • 0
                                          Тут терминологическая путаница.
                                          «Self-XSS» — это, строго говоря, не совсем XSS, это гораздо больше и вообще о другом. Это выполнение произвольного кода в консоли. Этот код может, например, выполнять GET, а затем POST-запрос. С точки зрения вектора атаки на приложение — это будет CSRF. Да, можно было бы попытаться провести XSS самого сайта (но сложнее т.к. проще поместить «рекомендации» по действиям с консолью на другой сайт). Ваш сайт даже может выводить предупреждение в консоль, как Фейсбук, ему это не поможет.
                                          TL;DR; Мой пример не про XSS.
                                          • 0
                                            Впрочем, да, в формулировке последнего комментария, от любого Self-XSS, anti-CSRF-токен не защищает.

                                            Меня смутило «Я уже писал, от XSS не спасает ничего.» по поводу CSRF-примера.
                                            • 0
                                              Ну и в догонку: я правильно понял, что в своих SPA Вы anit-CSRF токены не используете?
                                            • 0

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


                                              Токен генерируется сервером? Отлично, попросим сервер сгенерировать нам токен.


                                              Токен записан в LocalStorage? Отлично, прочитаем его оттуда.


                                              Токен записан в локальную переменную в замыкании? Отлично, переопределим fetch или JSON.stringify и дождемся когда приложение само отдаст нам токен.

                                              • 0
                                                Нет сомнений, я за минуту до Вашего коммента об этом написал.
            • –1
              Спасибо! Отличная статья для тех кто решился во всем разобраться!

              Только вот когда это читаешь, оторопь берет – неужели вот это ВСЁ нужно только для обеспечения аутентификации / авторизации. Бедные новички!

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

              Но для простых приложений, бывает быстрее накидать свою application-specific схему авторизации. Просто на основе базовых знаний протокола HTTP и известных уязвимостей. Чем разбираться со всеми этими claims, issuers, audiences, access-refresh tokens etc.
              • 0

                Возможно, но я лично придерживаюсь в этом вопросе принципа "never roll your own".

                • 0

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


                  Все туториалы вываливают сразу на человека кучу инфы. Я еще не видел, чтобы гайд по аутентификации был построен так:


                  • Вам нужна простая авторизация – вот минимальный набор действий.
                  • У Вас несколько приложений – настройте еще это и это.
                  • Нужно SSO – добавьте вот такую опцию.

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


                  И за всем этим очень трудно уследить. Для каждого протокола авторизации есть несколько версий. Для каждой версии – несколько реалкизаций со своими версиями. У каждой версии реализации новый формат конфигурации.

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

                    А зачем вам SSO с одним сервером и одним браузерным клиентом? Для таких случаев существует ASP.NET Identity


                    Или даже что-то свое на формах слепить можно

                    • 0
                      Или даже что-то свое на формах слепить можно

                      Гм. Но я же именно это и написал в изначальном комментарии :)

                  • 0

                    И есть еще один момент. Когда я настраиваю авторизацию по туториалу, меня не покидает ощущение – "а все ли я сделал правильно"? Я выполнил какие-то действия без понимания, как каждое из них влияет на безопасность приложения. Защищает ли описанная в туториале конфигурация именно от тех видов атак, от которых я планирую защитить приложение?


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

                    • 0
                      1. В принципе, ощущение игрушечности существующих туториалов и было одной из мотиваций того, что я написал yet another. По моей задумке, он должен быть в этом смысле лучше.
                      2. Вы в любом случае будете делать пентесты, скорее всего ещё и будете использовать автоматические.
                      • +1
                        Тут наверное вообще присутствует фундаментальная проблема туториалов.
                        В любом туториале (не обязательно по безопасности) рассказывается КАК сделать что-то.
                        А вот ЗАЧЕМ делать что-то не рассказывается, потому что без этого приложение как правило не будет работать вообще. Но вот с уязвимостями как раз нет очевидных вещей.

                        Например, если не прописать connection string – то мы не сможем подключиться к БД.

                        А если не проставить в авторизационный токен expiration time, то приложение будет работать? Да. Но появится уязвимость.

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