Админ панель: защита

144 Исходный код проекта

В предыдущих статьях мы добавили в приложение GameStore поддержку администрирования, и вы наверняка заметили, что если развернуть приложение в текущем состоянии, то модифицировать каталог товаров смог бы любой пользователь. Все что для этого требуется знать - URL, предназначенный для доступа к функциям администрирования, который выглядит как Admin/Index.

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

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

В этой статье мы только слегка коснемся доступных средств безопасности. Частично это объясняется тем, что они относятся к платформе ASP.NET, а не MVC Framework, а частично потому, что существует и множество других подходов. Средства аутентификации и авторизации подробно рассматриваются в разделе "Безопасность в ASP.NET".

Создание базовой политики безопасности

Мы начнем с конфигурирования аутентификации с помощью форм, которая является одним из способов аутентификации пользователей в приложении ASP.NET. В примере ниже показаны добавления, внесенные в файл Web.config проекта GameStore.WebUI (в тот, который находится в корневой папке проекта, а не в одной из папок представлений).

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  ...
  <system.web>
    <compilation debug="true" targetFramework="4.5.1" />
    <httpRuntime targetFramework="4.5.1" />
    <authentication mode="Forms">
      <forms loginUrl="~/Account/Login" timeout="2880"></forms>
    </authentication>
  </system.web>
</configuration>

Аутентификация настраивается с использованием элемента <authentication>, атрибут mode которого устанавливается в Forms, что указывает на аутентификацию с помощью форм, чаще всего применяемую в веб-приложениях. В ASP.NET 4.5.1 была добавлена поддержка более широкого диапазона вариантов аутентификации, подходящих для веб-приложений. Мы собираемся придерживаться аутентификации с помощью форм, поскольку она работает с локальными пользовательскими учетными данными и проста в настройке и управлении.

Главными альтернативами аутентификации с помощью форм являются аутентификация Windows, при которой для идентификации пользователей применяются учетные данные операционной системы, и аутентификация через учетную запись организации (Organizational Account), когда пользователь идентифицируется с использованием облачной службы, подобной Windows Azure. Мы не будем подробно рассматривать указанные варианты, т.к. они не особенно широко применяются в веб-приложениях.

Атрибут loginUrl сообщает ASP.NET, куда перенаправлять пользователей, когда они нуждаются в аутентификации (URL в данном случае выглядит как ~/Account/Login), а в атрибуте timeout указан период, в течение которого пользователь остается аутентифицированным после успешного входа, выраженный в минутах (2880 минут, т.е. 48 часов).

Следующий шаг предусматривает указание ASP.NET о том, где брать детальные сведения о пользователях приложения. Это вынесено в отдельный шаг, поскольку здесь будет делаться то, что ни в коем случае не должно делаться в реальном проекте: определение имени пользователя и пароля в файле Web.config. Изменения, внесенные в этот файл, показаны в примере ниже:

...
<authentication mode="Forms">
      <forms loginUrl="~/Account/Login" timeout="2880">
        <credentials passwordFormat="Clear">
          <user name="admin" password="12345"/>
        </credentials>
      </forms>
</authentication>
...

Мы хотим сохранить пример простым и сосредоточиться на способе, которым инфраструктура MVC Framework позволяет применять аутентификацию и авторизацию в веб-приложении. Однако помещение учетных данных в файл Web.config - верный путь к аварийной ситуации, особенно когда атрибут passwordFormat элемента credentials установлен в Clear, что означает хранение паролей в виде обычного текста.

Несмотря на неприемлемость для реальных проектов, использование файла Web.config для хранения учетных данных позволяет сосредоточиться на средствах MVC, не отвлекаясь на аспекты ядра платформы ASP.NET. В результате показанных выше добавлений в файл Web.config мы имеем жестко закодированные имя пользователя (admin) и пароль (12345).

Применение авторизации с помощью фильтров

В MVC Framework имеется мощное средство, которое называется фильтрами. Фильтры ASP.NET MVC представляют собой атрибуты .NET, которые можно применять к методу действия или классу контроллера, и они привносят дополнительную логику во время обработки запроса, чтобы изменить поведение инфраструктуры MVC Framework.

Доступны различные виды фильтров; кроме того, можно создавать и собственные специальные фильтры. В настоящий момент нас интересует стандартный фильтр авторизации - Authorize. В примере ниже демонстрируется его применение к контроллеру Admin:

[Authorize]
public class AdminController : Controller
{
    // ...
}

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

Фильтры можно применять к отдельному методу действия или контроллеру. В случае применения фильтра к контроллеру он будет оказывать влияние на каждый метод действия в классе контроллера. В примере выше фильтр Authorize был применен к классу, поэтому все методы действий в контроллере Admin доступны только аутентифицированным пользователям.

Чтобы увидеть эффект, который дает фильтр Authorize, запустите приложение и перейдите на URL вида /Admin/Index. Будет выдано сообщение об ошибке, подобное показанное на рисунке ниже:

Эффект от применения фильтра Authorize

Когда вы пытаетесь получить доступ к методу действия Index() контроллера Admin, инфраструктура MVC Framework обнаруживает фильтр Authorize. Поскольку вы не аутентифицированы, происходит перенаправление на URL, указанный в разделе аутентификации с помощью форм внутри файла Web.config, т.е. на /Account/Login. Контроллер Account пока еще не создан (это и приводит к возникновению ошибки, представленной на рисунке), однако сам факт, что инфраструктура MVC Framework попыталась перенаправить запрос, свидетельствует о работе атрибута Authorize.

Создание поставщика аутентификации

Использование средства аутентификации с помощью форм требует вызова двух статических методов класса System.Web.Security.FormsAuthentication:

Проблема с вызовом статических методов внутри методов действий состоит в том, что это затрудняет модульное тестирование контроллера: инфраструктуры имитации обычно позволяют имитировать только члены экземпляров. Классы, которые входят в состав MVC Framework, были спроектированы с учетом модульного тестирования, но класс FormsAuthentication был создан до построения дружественного к модульному тестированию проектного решения MVC.

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

Мы начнем с определения интерфейса поставщика аутентификации. Создайте новую папку под названием Abstract в папке Infrastructure проекта GameStore.WebUI и добавьте новый интерфейс по имени IAuthProvider. Код этого интерфейса показан в примере ниже:

namespace GameStore.WebUI.Infrastructure.Abstract
{
    public interface IAuthProvider
    {
        bool Authenticate(string username, string password);
    }
}

Теперь можно создать реализацию этого интерфейса, которая будет служить оболочкой для статических методов класса FormsAuthentication. Создайте еще одну новую папку внутри папки Infrastructure - на этот раз под названием Concrete - и затем новый класс по имени FormAuthProvider. Код этого класса приведен в примере ниже:

using System.Web.Security;
using GameStore.WebUI.Infrastructure.Abstract;

namespace GameStore.WebUI.Infrastructure.Concrete
{
    public class FormAuthProvider : IAuthProvider
    {
        public bool Authenticate(string username, string password)
        {
            bool result = FormsAuthentication.Authenticate(username, password);
            if (result)
                FormsAuthentication.SetAuthCookie(username, false);
            return result;
        }
    }
}

Среда Visual Studio выдаст предупреждение о том, что метод FormsAuthentication.Authenticate() был объявлен устаревшим. Это часть постоянно предпринимаемых Microsoft усилий по рационализации безопасности пользователей, которая является сложной областью в рамках любой инфраструктуры для разработки веб-приложений. Упомянутый устаревший метод вполне пригоден для целей данной статьи, и он позволяет выполнять аутентификацию с применением деталей, добавленных в файл web.config.

Реализация метода Authenticate() вызывает статические методы класса FormsAuthentication, которые мы хотим держать за пределами контроллера. Заключительный шаг состоит в регистрации FormAuthProvider в методе AddBindings() класса NinjectDependencyResolver, как показано в примере ниже:

// ...
using GameStore.WebUI.Infrastructure.Abstract;
using GameStore.WebUI.Infrastructure.Concrete;

namespace GameStore.WebUI.Infrastructure
{
    public class NinjectDependencyResolver : IDependencyResolver
    {
        // ...

        private void AddBindings()
        {
            // ...

            kernel.Bind<IAuthProvider>().To<FormAuthProvider>();
        }
    }
}

Создание контроллера Account

Следующая задача заключается в создании контроллера Account и метода действия Login(), на который произведена ссылка в файле Web.config. В действительности мы создадим две версии метода Login(). Первая версия будет визуализировать представление, которое содержит запрос на вход, а вторая - обрабатывать запрос POST, когда пользователь отправит свои учетные данные.

Начнем с создания класса модели представления, объект которого будет передаваться между контроллером и представлением. Добавьте в папку Models проекта GameStore.WebUI новый файл класса по имени LoginViewModel.cs и приведите его содержимое к виду, показанному в примере ниже:

using System.ComponentModel.DataAnnotations;

namespace GameStore.WebUI.Models
{
    public class LoginViewModel
    {
        [Required]
        public string UserName { get; set; }

        [Required]
        public string Password { get; set; }
    }
}

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

Далее создается контроллер Account, который будет обрабатывать аутентификацию. Добавьте в папку Controllers новый файл класса по имени AccountController.cs и приведите его содержимое в соответствие с кодом:

using System.Web.Mvc;
using GameStore.WebUI.Infrastructure.Abstract;
using GameStore.WebUI.Models;

namespace GameStore.WebUI.Controllers
{
    public class AccountController : Controller
    {
        IAuthProvider authProvider;
        public AccountController(IAuthProvider auth)
        {
            authProvider = auth;
        }

        public ViewResult Login()
        {
            return View();
        }

        [HttpPost]
        public ActionResult Login(LoginViewModel model, string returnUrl)
        {

            if (ModelState.IsValid)
            {
                if (authProvider.Authenticate(model.UserName, model.Password))
                {
                    return Redirect(returnUrl ?? Url.Action("Index", "Admin"));
                }
                else
                {
                    ModelState.AddModelError("", "Неправильный логин или пароль");
                    return View();
                }
            }
            else
            {
                return View();
            }
        }
	}
}

Создание представления

Для построения представления, которое будет запрашивать у пользователей их учетные данные, создайте папку Views/Account в папке проекта GameStore.WebUI. Щелкните на новой папке правой кнопкой мыши, выберите в контекстном меню пункт Add --> MVC 5 View Page (Razor) (Добавить --> Страница представления MVC 5 (Razor)), установите имя в Login и щелкните на кнопке ОК, чтобы создать файл Login.cshtml. Приведите содержимое нового файла в соответствие с кодом ниже:

@model GameStore.WebUI.Models.LoginViewModel

@{
    ViewBag.Title = "Login";
    Layout = "~/Views/Shared/_AdminLayout.cshtml";
}

<div class="panel">
    <div class="panel-heading">
        <h3>Вход в систему</h3>
    </div>
    <div class="panel-body">
        <p class="lead">Пожалуйста, войдите в систему, чтобы получить доступ к админ. панели:</p>
        @using (Html.BeginForm())
        {
            @Html.ValidationSummary()
            <div class="form-group">
                <label>Имя пользователя:</label>
                @Html.TextBoxFor(m => m.UserName, new { @class = "form-control" })
            </div>
            <div class="form-group">
                <label>Пароль:</label>
                @Html.PasswordFor(m => m.Password, new { @class = "form-control" })
            </div>
            <input type="submit" value="Войти" class="btn btn-primary" />
        }
    </div>
</div>

В этом представление используется компоновка _AdminLayout.cshtml и классы Bootstrap для стилизации содержимого. Никаких новых приемов в данном представлении не применяется, а только вспомогательный метод Html.PasswordFor(), который генерирует элемент <input> с атрибутом type, установленным в password.

Чтобы посмотреть, как выглядит это представление, запустите приложение и перейдите на URL вида /Admin/Index:

Внешний вид представления Login

Атрибуты Required, примененные к свойствам модели представления, приводят к использованию проверки достоверности на стороне клиента. (Вспомните, что требуемые для этого JavaScript-библиотеки были включены в компоновки _AdminLayout.cshtml.) Пользователи могут отправлять форму только после того, как предоставят имя пользователя и пароль, а аутентификация производится на сервере при вызове метода FormsAuthentication.Authenticate().

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

При получении некорректных учетных данных мы добавляем информацию об ошибке в ModelState и заново визуализируем представление. Это приводит к отображению сообщения об ошибке внутри области сводки по проверке, которая создается вызовом вспомогательного метода Html.ValidationSummary() в представлении. В результате обеспечивается защита административных функций приложения GameStore. Пользователям будет разрешен доступ к указанным функциям только после предоставления ими допустимых учетных данных и получения cookie-набора, который будет присоединяться к последующим запросам.

Модульное тестирование: аутентификация

Тестирование контроллера Account требует проверки двух аспектов поведения: пользователь должен быть аутентифицирован, если предоставил правильные учетные данные, и пользователь не должен быть аутентифицирован, если предоставил некорректные учетные данные. Эти тесты можно выполнить за счет создания имитированных реализаций интерфейса IAuthProvider и проверки типа и природы результата, возвращенного методом Login() контроллера. Мы создали приведенные ниже тесты в новом файле модульных тестов по имени AdminSecurityTests.cs:

using System.Web.Mvc;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using GameStore.WebUI.Controllers;
using GameStore.WebUI.Infrastructure.Abstract;
using GameStore.WebUI.Models;

namespace GameStore.UnitTests
{
    [TestClass]
    public class AdminSecurityTests
    {
        [TestMethod]
        public void Can_Login_With_Valid_Credentials()
        {
            // Организация - создание имитации поставщика аутентификации
            Mock<IAuthProvider> mock = new Mock<IAuthProvider>();
            mock.Setup(m => m.Authenticate("admin", "12345")).Returns(true);

            // Организация - создание модели представления
            // с правильными учетными данными
            LoginViewModel model = new LoginViewModel
            {
                UserName = "admin",
                Password = "12345"
            };

            // Организация - создание контроллера
            AccountController target = new AccountController(mock.Object);

            // Действие - аутентификация
            ActionResult result = target.Login(model, "/MyURL");

            // Утверждение
            Assert.IsInstanceOfType(result, typeof(RedirectResult));
            Assert.AreEqual("/MyURL", ((RedirectResult)result).Url);
        }

        [TestMethod]
        public void Cannot_Login_With_Invalid_Credentials()
        {
            // Организация - создание имитации поставщика аутентификации
            Mock<IAuthProvider> mock = new Mock<IAuthProvider>();
            mock.Setup(m => m.Authenticate("badUser", "badPass")).Returns(false);

            // Организация - создание модели представления
            // с некорректными учетными данными
            LoginViewModel model = new LoginViewModel
            {
                UserName = "badUser",
                Password = "badPass"
            };

            // Организация - создание контроллера
            AccountController target = new AccountController(mock.Object);

            // Действие - аутентификация
            ActionResult result = target.Login(model, "/MyURL");

            // Утверждение
            Assert.IsInstanceOfType(result, typeof(ViewResult));
            Assert.IsFalse(((ViewResult)result).ViewData.ModelState.IsValid);
        }
    }
}
Пройди тесты
Лучший чат для C# программистов