Asp net core способы авторизации

Аутентификация и авторизация

Аутентификация на основе куки. Часть 1

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

Рассмотрим на примере, как использовать простейшую аутентификацию в ASP.NET Core. Создадим новый проект Web Application (Model-View-Controller):

Вначале опеределим в проекте в папке Models модель пользователя — класс User :

Для взаимодействия с MS SQL Server через Entity Framework добавим в проект через Nuget пакет Microsoft.EntityFrameworkCore.SqlServer . А затем добавим в папку Models класс контекста данных UsersContext :

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

Изменим класс Startup для установки сервисов Entity Framework и подключения функицонала аутентификации и авторизации:

Здесь надо отметить два момента. Во-первых, в методе ConfigureServices() производится установка и настройка всех необходимых сервисов:

Для установки аутентификации с помощью куки в вызов services.AddAuthentication передается значение CookieAuthenticationDefaults.AuthenticationScheme . Далее с помощью метода AddCookie() настраивается аутентификация. По сути в этом методе производится конфигурация объекта CookieAuthenticationOptions , который описывает параметры аутентификации с помощью кук. В частности, в данном случае использовано одно свойство CookieAuthenticationOptions — LoginPath . Это свойство устанавливает относительный путь, по которому будет перенаправляться анонимный пользователь при доступе к ресурсам, для которых нужна аутентификация.

Второй момент — в методе Configure() внедряем в конвейер необходимые компоненты middleware:

Метод app.UseAuthentication() встраивает в конвейер компонент AuthenticationMiddleware, который управляет аутентификацией. Его вызов позволяет установить значение для свойства HttpContext.User . И метод app.UseAuthorization() встраивает в конвейер компонент AuthorizationMiddleware, который управляет авторизацией пользователей и разграничивает доступ к ресурсам.

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

Источник

Общие сведения о проверке подлинности в ASP.NET Core

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

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

Зарегистрированные обработчики проверки подлинности и их параметры конфигурации называются «схемами».

Схемы проверки подлинности задаются путем регистрации служб проверки подлинности в Startup.ConfigureServices следующими способами:

  • После вызова services.AddAuthentication (например, AddJwtBearer или AddCookie ) выполняется вызов метода расширения для конкретной схемы. Эти методы расширения используют AuthenticationBuilder.AddScheme для регистрации схем с необходимыми параметрами.
  • В более редких случаях выполняется вызов AuthenticationBuilder.AddScheme напрямую.

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

В AddAuthentication параметр JwtBearerDefaults.AuthenticationScheme представляет собой имя схемы, применяемой по умолчанию, когда конкретная схема не запрашивается.

Если используется множество схем, в политиках (или атрибутах) авторизации можно указать конкретные схемы, применяемые для проверки подлинности пользователя. В приведенном выше примере можно специально задать использование схемы на основе файлов cookie, указав ее имя (по умолчанию — CookieAuthenticationDefaults.AuthenticationScheme , однако при вызове AddCookie можно указать другое).

В некоторых случаях AddAuthentication автоматически вызывается другими методами расширения. Например, при использовании ASP.NET Core Identity выполняется внутренний вызов AddAuthentication .

ПО промежуточного слоя для проверки подлинности добавляется в Startup.Configure путем вызова метода расширения UseAuthentication в интерфейсе IApplicationBuilder приложения. Вызов UseAuthentication регистрирует ПО промежуточного слоя, использующее зарегистрированные ранее схемы проверки подлинности. Следует вызывать UseAuthentication перед вызовом любого ПО промежуточного слоя, требующего проверки подлинности пользователей. При использовании маршрутизации конечных точек метод UseAuthentication необходимо вызывать в следующем порядке:

  • После UseRouting , чтобы были доступны сведения о маршрутизации, необходимые для принятия решений о проверке подлинности.
  • До UseEndpoints , чтобы пользователи проходили проверку подлинности перед доступом к конечным точкам.

Проверка подлинности: основные понятия

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

  • Схема проверки подлинности
  • Схема проверки подлинности по умолчанию, описанная в следующем разделе.
  • Непосредственное задание параметра HttpContext.User.

Автоматическая проверка схем не предусмотрена. Если схема по умолчанию не указана, ее следует указать в атрибуте авторизации. В противном случае возникает следующая ошибка:

InvalidOperationException: Не указана authenticationScheme и не удалось найти DefaultAuthenticateScheme. Схемы по умолчанию можно задать с помощью AddAuthentication(string defaultScheme) или AddAuthentication(Action configureOptions).

Схема проверки подлинности

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

Схема проверки подлинности — это имя, соответствующее следующим элементам:

  • обработчик проверки подлинности;
  • параметры для настройки конкретного экземпляра обработчика.
Читайте также:  Каким способом легче запомнить таблицу умножения

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

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

Обработчик проверки подлинности

Обработчик проверки подлинности:

  • является типом, который реализует действия схемы;
  • является производным от IAuthenticationHandler или AuthenticationHandler ;
  • служит главным образом для проверки подлинности пользователей.

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

  • создают объекты AuthenticationTicket, представляющие удостоверение пользователя, если проверка подлинности прошла успешно;
  • возвращают «результат отсутствует» или «сбой», если проверка подлинности не пройдена;
  • поддерживают методы для действий проверочного запроса или запрета при попытке пользователей получить доступ к ресурсам в следующих случаях:
    • отсутствуют права доступа (запрет);
    • не пройдена проверка подлинности (проверочный запрос).

RemoteAuthenticationHandler и AuthenticationHandler

RemoteAuthenticationHandler — это класс для проверки подлинности, для которого требуется шаг удаленной проверки подлинности. По завершении шага удаленной проверки подлинности обработчик выполняет обратный вызов к CallbackPath (задано обработчиком). Обработчик завершает этап проверки подлинности, используя сведения, передаваемые в путь обратного вызова HandleRemoteAuthenticateAsync. Этот метод используется OAuth 2.0 и OIDC, но не JWT и cookie из-за невозможности напрямую использовать заголовок носителя и cookie для проверки подлинности. В этом случае удаленно размещенный поставщик

  • является поставщиком проверки подлинности.
  • Примеры: Facebook, Twitter, Google, Microsoft и любой другой поставщик OIDC, который обрабатывает проверку подлинности пользователей с помощью механизма обработчиков.

Authenticate

Действие проверки подлинности в схеме отвечает за получение удостоверения пользователя в зависимости от контекста запроса. Оно возвращает объект AuthenticateResult, который указывает, выполнена ли проверка подлинности, и, если да, включает удостоверение пользователя в билете проверки подлинности. См. раздел AuthenticateAsync. Примеры проверки подлинности:

  • Схема проверки подлинности на основе cookie получает удостоверение пользователя из файлов cookie.
  • Схема на основе носителя JWT десериализирует и проверяет токен носителя JWT для получения удостоверения пользователя.

Задача

Проверочный запрос вызывается процессом авторизации, когда пользователь, не прошедший проверку подлинности, запрашивает доступ к конечной точке, требующей проверку. Запрос выдается, например, если анонимный пользователь обращается к ресурсу с ограниченным доступом или щелкает какую-то ссылку для входа. Процесс авторизации выдает запрос с использованием указанных схем проверки подлинности. Если схемы не заданы, применяется схема по умолчанию. См. раздел ChallengeAsync. Примеры проверочных запросов:

  • Схема проверки подлинности на основе файлов cookie перенаправляет пользователя на страницу входа.
  • Схема на основе носителя JWT возвращает результат 401 с заголовком www-authenticate: bearer .

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

Запрет

Действие запрета в схеме проверки подлинности вызывается процессом авторизации, когда прошедший проверку подлинности пользователь пытается обратиться к ресурсу, к которому у него нет прав доступа. См. раздел ForbidAsync. Примеры запрета проверки подлинности:

  • Схема проверки подлинности на основе файлов cookie перенаправляет пользователя на страницу с сообщением об отказе в доступе.
  • Схема на основе носителя JWT возвращает результат 403.
  • Настраиваемая схема проверки подлинности перенаправляет пользователя на страницу, где он может запросить доступ к ресурсу.

Действие запрета может сообщить пользователю следующее:

  • Проверка подлинности пройдена.
  • Доступ к запрошенному ресурсу не разрешен.

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

Поставщики проверки подлинности для отдельного клиента

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

Orchard Core — это:

  • модульная платформа с открытым исходным кодом и инфраструктура для приложений с несколькими клиентами, созданная на основе ASP.NET Core;
  • система управления содержимым (CMS), созданная на основе этой платформы приложений.

Этот пример кода для Orchard Core содержит реализацию поставщиков проверки подлинности отдельно для каждого клиента.

Источник

Авторизация в ASP.NET Core MVC

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

Claims

Принципы авторизации и аутентификации в ASP.NET Core MVC не изменились по сравнению с предыдущей версией фреймворка, отличаясь лишь в деталях. Одним из относительно новых понятий является claim-based авторизация, с нее мы и начнем наше путешествие. Что же такое claim? Это пара строк «ключ-значение», в качестве ключа может выступать «FirstName», «EmailAddress» и т.п. Таким образом, claim можно трактовать как свойство пользователя, как строку с данными, или даже как некоторое утверждение вида «у пользователя есть что-то«. Знакомая многим разработчикам одномерная role-based модель органично содержится в многомерной claim-based модели: роль (утверждение вида «у пользователя есть роль X«) представляет собой один из claim и содержится в списке преопределенных System.Security.Claims.ClaimTypes. Не возбраняется создавать и свои claim.

Следующее важное понятие — identity. Это единое утверждение, содержащее набор claim. Так, identity можно трактовать как цельный документ (паспорт, водительские права и др.), в этом случае claim — строка в паспорте (дата рождения, фамилия. ). В Core MVC используется класс System.Security.Claims.ClaimsIdentity.

Читайте также:  Ключевые способы закрепления теоретического материала

Еще на уровень выше находится понятие principal, обозначающее самого пользователя. Как в реальной жизни у человека может быть на руках несколько документов одновременно, так и в Core MVC — principal может содержать несколько ассоциированных с пользователем identity. Всем известное свойство HttpContext.User в Core MVC имеет тип System.Security.Claims.ClaimsPrincipal. Естественно, через principal можно получить все claim каждого identity. Набор из более чем одного identity может использоваться для разграничения доступа к различным разделам сайта/сервиса.

На диаграмме указаны лишь некоторые свойства и методы классов из пространства имен System.Security.Claims.

Зачем это все нужно? При claim-based авторизации, мы явно указываем, что пользователю необходимо иметь нужный claim (свойство пользователя) для доступа к ресурсу. В простейшем случае, проверяется сам факт наличия определенного claim, хотя возможны и куда более сложные комбинации (задаваемые при помощи policy, requirements, permissions — мы подробно рассмотрим эти понятия ниже). Пример из реальной жизни: для управления легковым авто, у человека должны быть водительские права (identity) с открытой категорией B (claim).

Подготовительные работы

Здесь и далее на протяжении статьи, мы будем настраивать доступ для различных страниц веб-сайта. Для запуска представленного кода, достаточно создать в Visual Studio 2015 новое приложение типа «ASP.NET Core Web Application», задать шаблон Web Application и тип аутентификации «No Authentication».

При использовании аутентификации «Individual User Accounts» был бы сгенерирован код для хранения и загрузки пользователей в БД посредством ASP.NET Identity, EF Core и localdb. Что является совершенно избыточным в рамках данной статьи, даже несмотря на наличие легковесного EntityFrameworkCore.InMemory решения для тестирования. Более того, нам в принципе не потребуется библиотека аутентификации ASP.NET Identity. Получение principal для авторизации можно самостоятельно эмулировать in-memory, а сериализация principal в cookie возможна стандартными средствами Core MVC. Это всё, что нужно для нашего тестирования.

Для эмуляции хранилища пользователей достаточно открыть Startup.cs и зарегистрировать сервисы-заглушки во встроенном DI-контейнере:

Кстати, мы всего лишь проделали ту же работу, что проделал бы вызов AddEntityFrameworkStores :

Начнем с авторизации пользователя на сайте: на GET /Home/Login нарисуем форму-заглушку, добавим кнопку для отправки пустой формы на сервер. На POST /Home/Login вручную создадим principal, identity и claim (в реальном приложении эти данные были бы получены из БД). Вызов HttpContext.Authentication.SignInAsync сериализует principal и поместит его в зашифрованный cookie, который в свою очередь будет прикреплен к ответу веб-сервера и сохранен на стороне клиента:

Включим cookie-аутентификацию в методе Startup.Configure(app):

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

Атрибут Authorize и политики доступа

Атрибут [Authorize] никуда не делся из MVC. По-прежнему, при маркировке controller/action этим атрибутом — доступ внутрь получит только авторизованный пользователь. Вещи становятся интереснее, если дополнительно указать название политики (policy) — некоторого требования к claim пользователя:

Политики создаются в уже известном нам методе Startup.ConfigureServices :

Такая политика устанавливает, что попасть на страницу About сможет только авторизованный пользователь с claim-ом «age», при этом значение claim не учитывается. В следующем разделе, мы перейдем к примерам посложнее (наконец-то!), а сейчас разберемся, как это работает внутри?

[Authorize] — атрибут маркерный, сам по себе логики не содержащий. Нужен он лишь для того, чтобы указать MVC, к каким controller/action следует подключить AuthorizeFilter — один из встроенных фильтров Core MVC. Концепция фильтров та же, что и в предыдущих версиях фреймворка: фильтры выполняются последовательно, и позволяют выполнить код до и после обращения к controller/action. Важное отличие от middleware: фильтры имеют доступ к специфичному для MVC контексту (и выполняются, естественно, после всех middleware). Впрочем, грань между filter и middleware весьма расплывчата, так как вызов middleware возможно встроить в цепочку фильтров при помощи атрибута [MiddlewareFilter].

Вернемся к авторизации и AuthorizeFilter. Самое интересное происходит в его методе OnAuthorizationAsync:

  1. Из списка политик выбирается нужная на основе указанного в атрибуте [Authorize] значения (либо берется AuthorizationPolicy — политика по-умолчанию, содержащая всего одно требование с говорящим названием — DenyAnonymousAuthorizationRequirement.
  2. Выполняется проверка, соответствует ли набор из identity и claim-ов пользователя (например, полученных ранее из cookies запроса) требованиям политики.

Надеюсь, приведенные ссылки на исходный код дали вам представление об внутреннем устройстве фильтров в Core MVC.

Настройки политик доступа

Создание политик доступа через рассмотренный выше fluent-интерфейс не дает той гибкости, которая требуется в реальных приложениях. Конечно, можно явно указать допустимые значения claim через вызов RequireClaim(«x», params values) , можно скомбинировать через логическое И несколько условий, вызвав RequireClaim(«x»).RequireClaim(«y») . Наконец, можно навесить на controller и action разные политики, что, впрочем, приведет к той же комбинации условий через логическое И. Очевидно, что необходим более гибкий механизм создания политик, и он у нас есть: requirements и handlers.

Requirement — не более чем DTO для передачи параметров в соответствующий handler, который в свою очередь имеет доступ к HttpContext.User и волен налагать любые проверки на principal и содержащиеся в нем identity/claim. Более того, handler может получать внешние зависимости через встроенный в Core MVC DI-контейнер:

Регистрируем сам handler в Startup.ConfigureServices(), и он готов к использованию:

Handler-ы возможно сочетать как через AND, так и через OR. Так, при регистрации нескольких наследников AuthorizationHandler , все они будут вызваны. При этом вызов context.Succeed() не является обязательным, а вызов context.Fail() приводит к общему отказу в авторизации вне зависимости от результата других handler. Итого, мы можем комбинировать между собой рассмотренные механизмы доступа следующим образом:

  • Policy: AND
  • Requirement: AND
  • Handler: AND / OR.
Читайте также:  Хороший способ избавиться от клопов домашних

Resource-based авторизация

Как уже говорилось ранее, policy-based авторизация выполняется Core MVC в filter pipeline, т.е. ДО вызова защищаемого action. Успех авторизации при этом зависит только от пользователя — либо он обладает нужными claim, либо нет. А что, если необходимо учесть также защищаемый ресурс и его свойства, получить какие данные из внешних источников? Пример из жизни: защищаем action вида GET /Orders/ , считывающий по id строку с заказом из БД. Пусть наличие у пользователя прав на конкретный заказ мы сможем определить только после получения этого заказа из БД. Это автоматически делает непригодными рассмотренные ранее аспектно-ориентированные сценарии на основе фильтров MVC, выполняемых перед тем, как пользовательский код получает управление. К счастью, в Core MVC есть способы провести авторизацию вручную.

Для этого, в контроллере нам потребуется реализация IAuthorizationService . Получим ее, как обычно, через внедрение зависимости в конструктор:

Затем создадим новую политику и handler:

Наконец, проверяем пользователя + ресурс на соответствие нужной политике внутри action (заметьте, атрибут [Authorize] больше не нужен):

У метода IAuthorizationService.AuthorizeAsync есть перегрузка, принимающая список из requirement — вместо названия политики:

Что позволяет еще более гибко настраивать права доступа. Для демонстрации, используем преопределенный OperationAuthorizationRequirement (да, этот пример перекочевал в статью прямо с docs.microsoft.com):

что позволит вытворять следующие вещи:

В методе HandleRequirementAsync(context, requirement, resource) соответствующего handler — нужно лишь проверить права соответственно операции, указанной в requirement.Name и не забыть вызвать context.Fail() если пользователь провалил авторизацию:

Handler будет вызван столько раз, сколько requirement вы передали в AuthorizeAsync и проверит каждый requirement по-отдельности. Для единовременной проверки всех прав на операции за один вызов handler — передавайте список операций внутри requirement, например так:

На этом обзор возможностей resource-based авторизации закончен, и самое время покрыть наши handler-ы тестами:

Авторизация в Razor-разметке

Выполняемая непосредственно в разметке проверка прав пользователя может быть полезна для скрытия элементов UI, к которым пользователь не должен иметь доступ. Конечно же, во view можно передать все необходимые флаги через ViewModel (при прочих равных я за этот вариант), либо обратиться напрямую к principal через HttpContext.User:

Если вам интересно, то view наследуются от RazorPage класса, а прямой доступ к HttpContext из разметки возможен через свойство @Context .

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

Не пытайтесь использовать в нашем тестовом проекте вызов SignInManager.IsSignedIn(User) (используется в шаблоне веб-приложения с типом аутентификации Individual User Accounts). В первую очередь потому, что мы не используем библиотеку аутентификации Microsoft.AspNetCore.Identity , к которой этот класс принадлежит. Сам метод внутри не делает ничего, помимо проверки наличия у пользователя identity с зашитым в коде библиотеки именем.

Permission-based авторизация. Свой фильтр авторизации

Декларативное перечисление всех запрашиваемых операций (в первую очередь из числа CRUD) при авторизации пользователя, такое как:

… имеет смысл, если в вашем проекте построена система персональных разрешений (permissions): имеется некий набор из большого числа высокоуровневых операций бизнес-логики, есть пользователи (либо группы пользователей), которым были в ручном режиме выданы права на конкретные операции с конкретным ресурсом. К примеру, у Васи есть права «драить палубу», «спать в кубрике», а Петя может «крутить штурвал». Хорош или плох такой паттерн — тема для отдельной статьи (лично я от него не в восторге). Очевидная проблема данного подхода: список операций легко разрастается до нескольких сотен даже не в самой большой системе.

Ситуация упрощается, если для авторизации нет нужды учитывать конкретный экземпляр защищаемого ресурса, и наша система обладает достаточной гранулярностью, чтобы просто навесить на весь метод атрибут со списком проверяемых операций, вместо сотен вызовов AuthorizeAsync в защищаемом коде. Однако, использование авторизации на основе политик [Authorize(Policy = «foo-policy»)] приведет к комбинаторному взрыву числа политик в приложении. Почему бы не использовать старую добрую role-based авторизацию? В примере кода ниже, пользователю необходимо быть членом всех указанных ролей для получения доступа к FooController:

Подобное решение так же может не дать достаточной детализации и гибкости для системы с большим количеством permissions и их возможных комбинаций. Дополнительные проблемы начинаются, когда нужна и role-based и permission-based авторизация. Да и семантически, роли и операции — разные вещи, хотелось бы обрабатывать их авторизацию отдельно. Решено: пишем свою версию атрибута [Authorize] ! Продемонстрирую конечный результат:

Начнем с создания enum для операций, requirement и handler для проверки пользователя:

Ранее я рассказывал, что атрибут [Authorize] сугубо маркерный и нужен для применения AuthorizeFilter . Не будем бороться с существующей архитектурой, поэтому напишем по аналогии собственный фильтр авторизации. Поскольку список permissions у каждого action свой, то:

  1. Необходимо создавать экземпляр фильтра на каждый вызов;
  2. Невозможно напрямую создать экземпляр через встроенный DI-контейнер.

К счастью, в Core MVC эти проблемы легко разрешимы при помощи атрибута [TypeFilter]:

Мы получили полностью работающее, но безобразно выглядящее решение. Для того, чтобы скрыть детали реализации нашего фильтра от вызывающего кода, нам и пригодится атрибут [AuthorizePermission] :

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

Дополнительные материалы для чтения по теме (также приветствуются ваши ссылки для включения в список):

Источник

Оцените статью
Разные способы