Pro ASP.NET Core MVC2(第7版)翻译

第12章:运动商店:安全和部署

作者:Adam Freeman 翻译:陈广 日期:2018-9-5


在上一章中,我添加了对管理运动商店应用程序的支持,如果我按原样部署应用程序,您可能会注意到任何人都可以修改产品目录。他们只需要知道,管理功能可以使用 /Admin/Index 和 /Order/List URL。在本章,我将向您展示如何通过密码保护防止随机人员使用管理功能。一旦有了安全性,我将向您展示如何将 SportsStore 应用程序准备和部署到生产中。

保护管理功能

身份验证和授权由 ASP.NET Core Identity 系统提供,该系统集成到了 ASP.NET Core 平台和 MVC 应用程序中。在接下来的部分中,我将创建一个基本的安全设置,允许一个名为 Admin 的用户对应用程序中的管理功能进行身份验证和访问。ASP.NET Core Identity 为验证用户和授权访问应用程序功能和数据提供了更多的功能,您可以在第28、29和30章中找到更详细的信息,其中我将向您介绍如何创建和管理用户帐户、如何使用角色和策略以及如何支持来自第三方(如 Microsoft、Google、Facebook 和 Twitter)的身份验证。然而,本章的目标只是获得足够的功能,以防止客户访问 SportsStore 应用程序的敏感部分,并在这样做时,让您了解身份验证和授权如何融入 MVC 应用程序。

创建身份数据库

ASP.NET Identity 系统是无休止的可配置和可扩展的,并且支持许多关于如何存储其用户数据的选项。我将使用最常见的方法,即使用 Entity Framework Core 访问的 Microsoft SQL Server 来存储数据。

创建上下文类

我需要创建一个数据库上下文文件,作为数据库和它提供的访问身份模型对象之间的桥梁。我在 Models 文件夹中添加了一个名为 AppIdentityDbContext.cs 的类文件,并使用它来定义清单12-1所示的类。

注意:您可能已经习惯于将包添加到项目中,以获得其他功能,如安全性工作。但是,随着 ASP.NET Core 2 的发布,身份验证所需的 Nuget 包已经包含在项目中,该包是作为项目模板的一部分添加到 SportsStore.csproj 文件中的元包。

清单 12-1:Models 文件夹下的 AppIdentityDbContext.cs 文件的内容

using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace SportsStore.Models
{
    public class AppIdentityDbContext : IdentityDbContext<IdentityUser>
    {
        public AppIdentityDbContext(DbContextOptions<AppIdentityDbContext> options)
            : base(options) { }
    }
}

AppIdentityDbContext类派生自IdentityDbContext,它为 Entity Framework Core 提供了身份指定功能。对于类型参数,我使用了IdentityUser类,这是用于表示用户的内置类。在第28章中,我将演示如何使用可以扩展的自定义类来添加有关应用程序用户的额外信息。

定义连接字符串

下一步是定义数据库的连接字符串。在清单12-2中,您可以看到我对 SportsStore 项目的 appsettings.json 文件所做的添加,它与我在第8章中为产品数据库定义的连接字符串具有相同的格式。

清单 12-2:appsettings.json 文件,定义连接字符串

{
  "Data": {
    "SportStoreProducts": {
      "ConnectionString": "Server=(localdb)\\MSSQLLocalDB;Database=SportsStore;Trusted_Connection=True;MultipleActiveResultSets=true"
    },
    "SportStoreIdentity": {
      "ConnectionString": "Server=(localdb)\\MSSQLLocalDB;Database=Identity;Trusted_Connection=True;MultipleActiveResultSets=true"
    }
  }
}

请记住,连接字符串必须在 appsettings.json 文件中的单个未中断行中定义,清单中显示为多行中仅是由于图书页的固定宽度不足。清单中增加了一个名为SportsStoreIdentity的连接字符串,该字符串指定一个名为 Identity 的 LocalDB 数据库。

配置应用程序

与其他 ASP.NET Core 功能一样,Identity 是在Startup类中配置的。清单12-3显示了我使用前面定义的上下文类和连接字符串在 SportsStore 项目中设置 Identity 的新增内容。

清单 12-3:Startup.cs 文件,配置 Identity

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using SportsStore.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;

namespace SportsStore
{
    public class Startup
    {
        public Startup(IConfiguration configuration) =>
            Configuration = configuration;

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddDbContext<ApplicationDbContext>(options =>
                options.UseSqlServer(
                    Configuration["Data:SportStoreProducts:ConnectionString"]));

            services.AddDbContext<AppIdentityDbContext>(options =>
                options.UseSqlServer(Configuration["Data:SportStoreIdentity:ConnectionString"]));

            services.AddIdentity<IdentityUser, IdentityRole>()
                .AddEntityFrameworkStores<AppIdentityDbContext>()
                .AddDefaultTokenProviders();

            services.AddTransient<IProductRepository, EFProductRepository>();
            services.AddScoped<Cart>(sp => SessionCart.GetCart(sp));
            services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
            services.AddTransient<IOrderRepository, EFOrderRepository>();
            services.AddMvc();
            services.AddMemoryCache();
            services.AddSession();
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            app.UseDeveloperExceptionPage();
            app.UseStatusCodePages();
            app.UseStaticFiles();
            app.UseSession();
            app.UseAuthentication();
            app.UseMvc(routes =>
            {
                // ...routes omitted for brevity...
            });
            SeedData.EnsurePopulated(app);
        }
    }
}

ConfigureServices方法中,我扩展了 Entity Framework Core 配置来注册上下文类,并使用AddIdentity方法来设置Identity服务,使用内置类来表示用户和角色。在Configure方法中,我调用UseAuthentication方法来设置将拦截请求和响应以实现安全策略的组件。

创建和应用数据库迁移

基本配置已经就绪,是时候使用 Entity Framework Core 迁移特性来定义模式并将其应用于数据库了。打开一个新的命令提示符或 PowerShell 窗口,然后在 SportsStore 项目文件夹中运行以下命令,为身份数据库创建一个新的迁移:

dotnet ef migrations add Initial --context AppIdentityDbContext

与以前的数据库命令不同的是,我使用了-context参数来指定与我想要处理的数据库相关联的上下文类的名称,即AppIdentityDbContext。当应用程序中有多个数据库时,确保使用正确的上下文类非常重要。

一旦 Entity Framework Core 生成了初始迁移,运行以下命令来创建数据库并运行迁移命令:

dotnet ef database update --context AppIdentityDbContext

结果是一个名为 Identity 的新 LocalDB 数据库,您可以使用 Visual Studio 【SQL Server对象资源管理器】检查该数据库。

定义种子数据

当应用程序启动时,我将通过向数据库播种显式地创建 Admin 用户。我向 Models 文件夹中添加了一个名为 IdentitySeedData.cs 的类文件,并定义了清单12-4所示的静态类。

清单 12-4:Models 文件夹下的 IdentitySeedData.cs 文件的内容

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;

namespace SportsStore.Models
{
    public static class IdentitySeedData
    {
        private const string adminUser = "Admin";
        private const string adminPassword = "Secret123$";

        public static async void EnsurePopulated(IApplicationBuilder app)
        {
            UserManager<IdentityUser> userManager = app.ApplicationServices
                .GetRequiredService<UserManager<IdentityUser>>();
            IdentityUser user = await userManager.FindByIdAsync(adminUser);
            if (user == null)
            {
                user = new IdentityUser("Admin");
                await userManager.CreateAsync(user, adminPassword);
            }
        }
    }
}

此代码使用UserManager<T>类,它是 ASP.NET Core Identity 作为服务提供的,用于管理用户,如第28章所述。在数据库中搜索 Admin 用户帐户,如果不存在,则创建这个帐户,密码为Secret123$。不要在本例中更改硬编码密码,因为身份验证策略要求密码包含字符的数目和范围。有关如何更改验证设置的详细信息,请参阅第28章。

提示:通常需要对管理员帐户的细节进行硬编码,以便您可以在应用程序部署后登录并开始对其进行管理。当您这样做时,您必须记住更改您创建的帐户的密码。有关如何使用 Identity 更改密码的详细信息,请参阅第28章。

为了确保应用程序启动时 Identity 数据库已经播种,我将清单12-5所示的语句添加到Startup类的Configure方法中。

清单 12-5:SportsStore 文件夹下的 Startup.cs 文件,Identity 数据库播种

...
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    app.UseDeveloperExceptionPage();
    app.UseStatusCodePages();
    app.UseStaticFiles();
    app.UseSession();
    app.UseAuthentication();
    app.UseMvc(routes =>
    {
        // ...routes omitted for brevity...
    });
    SeedData.EnsurePopulated(app);
    IdentitySeedData.EnsurePopulated(app);
}
...

应用基本授权策略

现在我已经配置了 ASP.NET Core Identity,我可以将授权策略应用到我想要保护的应用程序的各个部分。我将使用最基本的授权策略,即允许任何经过身份验证的用户进行访问。虽然在实际应用程序中这也是一种有用的策略,但是也有一些选项可以创建更细粒度的授权控制(如第28、29和30章所述),但是由于 SportsStore 应用程序只有一个用户,所以区分匿名请求和已验证请求就足够了。

Authorize特性用于限制对 action 方法的访问,在清单12-6中,您可以看到我使用该特性来保护对 Order 控制器中的管理 action 的访问。

清单 12-6:OrderController.cs 文件中的限制访问

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

namespace SportsStore.Controllers
{
    public class OrderController : Controller
    {
        private IOrderRepository repository;
        private Cart cart;

        public OrderController(IOrderRepository repoService, Cart cartService)
        {
            repository = repoService;
            cart = cartService;
        }

        [Authorize]
        public ViewResult List() =>
            View(repository.Orders.Where(o => !o.Shipped));

        [HttpPost]
        [Authorize]
        public IActionResult MarkShipped(int orderID)
        {
            Order order = repository.Orders
            .FirstOrDefault(o => o.OrderID == orderID);
            if (order != null)
            {
                order.Shipped = true;
                repository.SaveOrder(order);
            }
            return RedirectToAction(nameof(List));
        }

        public ViewResult Checkout() => View(new Order());

        [HttpPost]
        public IActionResult Checkout(Order order)
        {
            if (cart.Lines.Count() == 0)
            {
                ModelState.AddModelError("", "Sorry, your cart is empty!");
            }
            if (ModelState.IsValid)
            {
                order.Lines = cart.Lines.ToArray();
                repository.SaveOrder(order);
                return RedirectToAction(nameof(Completed));
            }
            else
            {
                return View(order);
            }
        }
        public ViewResult Completed()
        {
            cart.Clear();
            return View();
        }
    }
}

我不想阻止未经身份验证的用户访问 Order 控制器中的其他 action 方法,因此我只将Authorize特性应用于ListMarkShipped方法。我想保护由 Admin 控制器定义的所有 action 方法,我可以通过向控制器类应用Authorize特性来实现这一点,然后控制器类将授权策略应用到它所包含的所有 action 方法,如清单12-7所示。

清单 12-7:AdminController.cs 文件,限制访问

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

namespace SportsStore.Controllers
{
    [Authorize]
    public class AdminController : Controller
    {
        private IProductRepository repository;
        public AdminController(IProductRepository repo)
        {
            repository = repo;
        }

        public ViewResult Index() => View(repository.Products);

        public ViewResult Edit(int productId) =>
            View(repository.Products
                .FirstOrDefault(p => p.ProductID == productId));

        [HttpPost]
        public IActionResult Edit(Product product)
        {
            if (ModelState.IsValid)
            {
                repository.SaveProduct(product);
                TempData["message"] = $"{product.Name} has been saved";
                return RedirectToAction("Index");
            }
            else
            {
                // there is something wrong with the data values
                return View(product);
            }
        }

        public ViewResult Create() => View("Edit", new Product());

        [HttpPost]
        public IActionResult Delete(int productId)
        {
            Product deletedProduct = repository.DeleteProduct(productId);
            if (deletedProduct != null)
            {
                TempData["message"] = $"{deletedProduct.Name} was deleted";
            }
            return RedirectToAction("Index");
        }
    }
}

创建 Account 控制器和视图

当未经身份验证的用户发送需要授权的请求时,它们被重定向到 Account/Login URL,应用程序可以使用该 URL 提示用户提供他们的凭证。在准备过程中,我添加了一个视图模型来表示用户的凭证,方法是将一个名为 LoginModel.cs 的类文件添加到 Models/ViewModels 文件夹中,并使用它来定义清单12-8所示的类。

清单 12-8:Models/ViewModels 文件夹下的 LoginModel.cs 文件的内容

using System.ComponentModel.DataAnnotations;

namespace SportsStore.Models.ViewModels
{
    public class LoginModel
    {
        [Required]
        public string Name { get; set; }

        [Required]
        [UIHint("password")]
        public string Password { get; set; }

        public string ReturnUrl { get; set; } = "/";
    }
}

NamePassword属性已使用Required特性进行修饰,该特性使用模型验证来确保提供了值。Password属性已使用UIHint特性进行修饰,以便当我在 login Razor 视图中的input元素上使用asp-for属性时,标签助手将type属性设置为password;这样,用户输入的文本在屏幕上是不可见的。我在第24章中描述了UIHint特性的使用。

接下来,我在 Controllers 文件夹中添加了一个名为 AccountController.cs 的类文件,并使用它来定义如清单12-9所示的控制器,它将响应对 /Account/Login URL 的请求。

清单 12-9:Controllers 文件夹下的 AccountController.cs 文件的内容

using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using SportsStore.Models.ViewModels;

namespace SportsStore.Controllers
{
    [Authorize]
    public class AccountController : Controller
    {
        private UserManager<IdentityUser> userManager;
        private SignInManager<IdentityUser> signInManager;
        public AccountController(UserManager<IdentityUser> userMgr,
        SignInManager<IdentityUser> signInMgr)
        {
            userManager = userMgr;
            signInManager = signInMgr;
        }

        [AllowAnonymous]
        public ViewResult Login(string returnUrl)
        {
            return View(new LoginModel
            {
                ReturnUrl = returnUrl
            });
        }

        [HttpPost]
        [AllowAnonymous]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Login(LoginModel loginModel)
        {
            if (ModelState.IsValid)
            {
                IdentityUser user =
                await userManager.FindByNameAsync(loginModel.Name);
                if (user != null)
                {
                    await signInManager.SignOutAsync();
                    if ((await signInManager.PasswordSignInAsync(user,
                    loginModel.Password, false, false)).Succeeded)
                    {
                        return Redirect(loginModel?.ReturnUrl ?? "/Admin/Index");
                    }
                }
            }
            ModelState.AddModelError("", "Invalid name or password");
            return View(loginModel);
        }
        public async Task<RedirectResult> Logout(string returnUrl = "/")
        {
            await signInManager.SignOutAsync();
            return Redirect(returnUrl);
        }
    }
}

当用户被重定向到 /Account/Login URL 时,Login action 方法的 GET 版本将渲染页面的默认视图,提供一个视图模型对象,其中包括如果身份验证请求成功,浏览器应该重定向到的 URL。

身份验证凭证被提交到 Login 方法的 POST 版本,该方法使用通过控制器构造函数接收到的UserManager<IdentityUser>SignInManager<IdentityUser>服务对用户进行身份验证并将它们登录到系统中。我在第28、29和30章中解释了这些类是如何工作的,但是现在只需知道如果存在身份验证失败,那么我就会创建一个模型验证错误并渲染默认视图;但是,如果身份验证成功,则在提示用户输入凭证之前,我会将用户重定向到他们希望访问的 URL。

提示:一般来说,使用客户端数据验证是一个好主意。它从服务器上剥离了一些工作,并给用户提供了关于他们提供的数据的即时反馈。但是,您不应该倾向于在客户端执行身份验证,因为这通常涉及向客户端发送有效的凭据,以便它们可以用于检查用户输入的用户名和密码,或者至少要信任客户端的报告,即他们是否成功地进行了身份验证。身份验证应该始终在服务器上进行。

为了提供渲染视图的 Login 方法,我创建了 Views/Account 文件夹,并添加了一个名为 Login.cshtml 的 Razor 视图文件,内容如清单12-10所示。

清单 12-10:Views/Account 文件夹下的 Login.cshtml 文件的内容

@model LoginModel

@{
    ViewBag.Title = "Log In";
    Layout = "_AdminLayout";
}

<div class="text-danger" asp-validation-summary="All"></div>
<form asp-action="Login" asp-controller="Account" method="post">
    <input type="hidden" asp-for="ReturnUrl" />
    <div class="form-group">
        <label asp-for="Name"></label>
        <div><span asp-validation-for="Name" class="text-danger"></span></div>
        <input asp-for="Name" class="form-control" />
    </div>
    <div class="form-group">
        <label asp-for="Password"></label>
        <div><span asp-validation-for="Password" class="text-danger"></span></div>
        <input asp-for="Password" class="form-control" />
    </div>
    <button class="btn btn-primary" type="submit">Log In</button>
</form>

最后一步是对共享管理布局进行更改,添加一个按钮,通过向 Logout action 发送请求将当前用户注销,如清单12-11所示。这是一个有用的特性,它使测试应用程序变得更容易,否则您需要清除浏览器的 cookies 才能返回到未经身份验证的状态。

清单 12-11:_AdminLayout.cshtml 文件,添加注销按钮

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <link rel="stylesheet" asp-href-include="lib/bootstrap/css/*.min.css" />
    <title>@ViewBag.Title</title>
    <style>
        .input-validation-error {
            border-color: red;
            background-color: #fee;
        }
    </style>
    <script src="/lib/jquery/jquery.min.js"></script>
    <script src="/lib/jquery-validate/jquery.validate.min.js"></script>
    <script src="/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>
</head>
<body class="m-1 p-1">
    <div class="bg-info p-2 row">
        <div class="col">
            <h4>@ViewBag.Title</h4>
        </div>
        <div class="col-2">
            <a class="btn btn-sm btn-primary"
               asp-action="Logout" asp-controller="Account">Log Out</a>
        </div>
    </div>
    @if (TempData["message"] != null)
    {
        <div class="alert alert-success">@TempData["message"]</div>
    }
    @RenderBody()
</body>
</html>

测试安全策略

一切就绪,您可以通过启动应用程序并请求 /Admin/Index URL 来测试安全策略。由于您目前还没有经过身份验证,并且试图访问需要授权的 action,您的浏览器将被重定向到 /Account/Login URL。输入AdminSecret123$作为用户名和密码,并提交表单。Account 控制器将检查您添加到 Identity 数据库的种子数据所提供的凭证,并假设您输入了正确的详细信息 —— 对您进行身份验证并将您重定向回您现在可以访问的 /Account/Login URL。图12-1演示了这个过程.

图12-1 管理 身份验证/授权 过程

布署应用程序

运动商店应用程序的所有特性和功能都已就绪,现在是准备将应用程序部署到生产中的时候了。ASP.NET Core MVC 应用程序可以使用许多托管选项,我在本章中使用的是 Microsoft Azure 平台,因为它来自微软,并且它提供免费帐户,这意味着您可以一直跟随运动商店示例,即使您不想在自己的项目中使用 Azure。

注意:本节您需要一个 Azure 帐户。如果还没有,可以在http://azure.microsoft.com创建一个免费帐户。

创建数据库

首先是创建生产中的应用程序将使用的数据库。这是您可以作为 Visual Studio 部署过程的一部分所做的事情,但这是一种“鸡与蛋”的情况,因为您需要在部署之前知道数据库的连接字符串,这是创建数据库的过程。

警告:当微软添加新功能并修改现有功能时,Azure 门户经常会发生变化。在我编写这些功能时,本节中的指令是准确的,但是,当您阅读这篇文章时,所需的步骤可能已经略有改变。基本的方法应该是相同的,但是数据字段的名称和步骤的确切顺序可能需要一些实验才能得到正确的结果。

最简单的方法是使用您的 Azure 帐户登录到http://portal.azure.com并手动创建数据库。登录后,选择【SQL Databases】资源类别,然后单击【Add】按钮创建新数据库。

对于第一个数据库,输入名称 Products。单击【Configure Required Settings】链接,然后是【Create a New Server】链接。输入一个新的服务器名称 —— 在整个 Azure 必须是独一无二的 —— 并选择数据库管理员用户名和密码。我输入了一个服务器名 sportsstorecore2db,它的管理员名称是 sportsstoreadmin,密码是 Secret123$。您必须使用不同的服务器名称,我建议您使用更健壮的密码。选择数据库的位置;单击【Select】按钮关闭选项,然后单击【Create】按钮创建数据库本身。Azure 将花费几分钟时间来执行创建过程,然后它将出现在SQL数据库资源类别中。

创建另一个 SQL 服务器,这一次输入名称 identity。您可以使用刚才创建的数据库服务器,而不是创建一个新的。结果是由 Azure 托管的两个SQL服务器数据库,详细信息如表12-1所示。您将拥有不同的数据库服务器名称,以及 —— 理想情况下 —— 更好的密码。

表 12-1:SportsStore 应用程序的 Azure 数据库

数据库名称 服务器名称 管理员 密码
products sportsstorecore2db sportsstoreadmin Secret123$
identity sportsstorecore2db sportsstoreadmin Secret123$

为配置打开防火墙访问权限

我需要用它们的架构填充数据库,最简单的方法就是打开 Azure 防火墙访问,这样我就可以从我的开发机器上运行 Entity Framework Core 命令。

选择【SQL Database】资源类别中的任一数据库,单击【Tools】按钮,然后在【Visual Studio】链接单击【Open】。现在点击【Configure Your Firewall】链接,单击【Add Client IP】按钮,然后单击【Save】。这允许您的当前IP地址到达数据库服务器并执行配置命令。(您可以通过单击【Open In Visual Studio】按钮检查数据库架构,该按钮将打开 Visual Studio 并使用 【SQL Server 对象资源管理器】检查数据库。)

获取连接字符串

我需要新数据库的连接字符串。通过【Show Database Connection Strings】链接单击【SQL Databases】资源类别中的数据库时,Azure 将提供此信息。连接字符串是为不同的开发平台提供的,.NET应用程序所需的是 ADO.NET 字符串。下面是 Azure 门户为产品数据库提供的连接字符串:

Server=tcp:sportsstorecore2db.database.windows.net,1433;Initial Catalog=products;Persist
Security Info=False;User ID={your_username};Password={your_password};MultipleActiveResultS
ets=True;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;

您将看到不同的配置选项,这取决于 Azure 如何提供您的数据库。注意,我用粗体标记的用户名和密码有占位符,在使用连接字符串配置应用程序时必须更改这些占位符。

准备应用程序

在部署应用程序之前,我有一些基本的准备工作要做,以便为生产环境做好准备。在下面的部分中,我改变了错误的显示方式,并为数据库设置了生产连接字符串。

创建 Error 控制器和视图

目前,应用程序被配置为使用对开发人员友好的错误页面,在出现问题时提供有用的信息。这不是最终用户应该看到的信息,所以我在控制器文件夹中添加了一个名为 ErrorController.cs 的类文件,并使用它来定义清单12-12所示的简单控制器。 清单 12-12:Controllers 文件夹下的 ErrorController.cs 文件的内容

using Microsoft.AspNetCore.Mvc;

namespace SportsStore.Controllers {
    public class ErrorController : Controller {
        public ViewResult Error() => View();
    }
}

控制器定义了一个渲染默认视图的 Error action。为了向控制器提供视图,我创建了 Views/Error 文件夹,添加了一个名为 Error.cshtml 的 Razor 视图文件,并应用了清单12-13所示的标记。

清单 12-13:Views/Error 文件夹下的 Error.cshtml 文件的内容

@{
    Layout = null;
}

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
    <title>Error</title>
</head>
<body>
    <h2 class="text-danger">Error.</h2>
    <h3 class="text-danger">An error occurred while processing your request.</h3>
</body>
</html>

这种错误页面是最后的手段,最好保持它尽可能简单,不要依赖共享视图、视图组件或其他的丰富特性。在这种情况下,我禁用了共享布局,并定义了一个简单的 HTML 文档,该文档解释了出现了一个错误,而没有提供任何关于发生了什么的信息。

定义生产数据库设置

下一步是创建一个文件,该文件将为应用程序提供生产中的数据库连接字符串。我将一个名为 appsettings.production.json 的新 ASP.NET 配置文件添加到运动商店项目中,并添加了清单12-14所示的内容。

提示:解决方案资源管理器将此文件嵌套在文件列表中的 appsettings.json 中,如果您希望稍后再次编辑该文件,则必须展开该文件。

清单 12-14:appsettings.production.json 文件的内容

{
    "Data": {
        "SportStoreProducts": {
            "ConnectionString": "Server=tcp:sportsstorecore2db.database.windows.net,1433;InitialCatalog=products;Persist Security Info=False;User ID={your_username};Password={your_password};MultipleActiveResultSets=True;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;"
        },
        "SportStoreIdentity": {
            "ConnectionString": "Server=tcp:sportsstorecore2db.database.windows.net,1433;InitialCatalog=identity;Persist Security Info=False;User ID={your_username};Password={your_password};MultipleActiveResultSets=True;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;"
        }
    }
}

此文件很难读取,因为连接字符串不能分割成多行。此文件的内容复制部分 appsettings.json 文件的连接字符串,但使用 Azure 连接字符串(请记住替换用户名和密码占位符)。我还将 MultipleActiveResultSets 设置为true,这允许多个并发查询,并避免了执行复杂的应用程序数据 LINQ 查询时出现的常见错误情况。

注意:在连接字符串中插入用户名和密码时,删除大括号字符,以便以Password=MyPassword而不是Password={MyPassword}结束。

配置应用程序

现在,我可以更改Startup类,以便应用程序在生产过程中的行为有所不同。清单12-15显示了我所做的更改。

清单 12-15:Startup.cs 文件,配置应用程序

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using SportsStore.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;
namespace SportsStore
{
    public class Startup
    {
        public Startup(IConfiguration configuration) =>
        Configuration = configuration;
        public IConfiguration Configuration { get; }
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddDbContext<ApplicationDbContext>(options =>
                options.UseSqlServer(
            Configuration["Data:SportStoreProducts:ConnectionString"]));
            services.AddDbContext<AppIdentityDbContext>(options =>
                options.UseSqlServer(
                    Configuration["Data:SportStoreIdentity:ConnectionString"]));
            services.AddIdentity<IdentityUser, IdentityRole>()
                .AddEntityFrameworkStores<AppIdentityDbContext>()
                .AddDefaultTokenProviders();
            services.AddTransient<IProductRepository, EFProductRepository>();
            services.AddScoped<Cart>(sp => SessionCart.GetCart(sp));
            services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
            services.AddTransient<IOrderRepository, EFOrderRepository>();
            services.AddMvc();
            services.AddMemoryCache();
            services.AddSession();
        }
        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
                app.UseStatusCodePages();
            }
            else
            {
                app.UseExceptionHandler("/Error");
            }
            app.UseStaticFiles();
            app.UseSession();
            app.UseAuthentication();
            app.UseMvc(routes => {
                routes.MapRoute(name: "Error", template: "Error",
                    defaults: new { controller = "Error", action = "Error" });
                routes.MapRoute(name: null,
                    template: "{category}/Page{productPage:int}",
                    defaults: new { controller = "Product", action = "List" }
                );
                routes.MapRoute(name: null, template: "Page{productPage:int}",
                    defaults: new
                    {
                        controller = "Product",
                        action = "List",
                        productPage = 1
                    }
                );
                routes.MapRoute(name: null, template: "{category}",
                    defaults: new
                    {
                        controller = "Product",
                        action = "List",
                        productPage = 1
                    }
                );
                routes.MapRoute(name: null, template: "",
                    defaults: new
                    {
                        controller = "Product",
                        action = "List",
                        productPage = 1
                    });
                routes.MapRoute(name: null, template: "{controller}/{action}/{id?}");
            });
            //SeedData.EnsurePopulated(app);
            //IdentitySeedData.EnsurePopulated(app);
        }
    }
}

IHostingEnvironment接口用于提供有关应用程序正在运行的环境(例如开发或生产)的信息。当宿主环境设置为生产时,ASP.NET Core 将加载 appsettings.production.json 文件及其内容,以覆盖 appsettings.json 文件中的设置,这意味着 Entity Framework Core 将连接到 Azure 数据库,而不是 LocalDB。有许多选项可用于在不同的环境中裁剪应用程序的配置,我在第14章中对此进行了解释。

我还注释掉了数据库播种的声明,我在《管理数据库种子》一节中对此作了解释。

应用数据库迁移

若要使用应用程序所需的架构设置数据库,请打开一个新的命令提示符或 PowerShell 窗口,并导航到 SportsStore 项目目录。设置环境以便 dotnet 命令行工具将使用用于 Azure 的连接字符串需要设置环境变量。如果使用 PowerShell,请使用此命令设置环境变量:

$env:ASPNETCORE_ENVIRONMENT="Production"

如果使用命令提示符,则使用此命令设置环境变量:

set ASPNETCORE_ENVIRONMENT=Production

在 SportsStore 项目文件夹中运行以下命令,将项目中的迁移应用于 Azure 数据库:

dotnet ef database update --context ApplicationDbContext
dotnet ef database update --context AppIdentityDbContext

环境变量指定用于获取连接字符串以到达数据库的宿主环境。如果这些命令不起作用,请确保您已经配置了 Azure 防火墙以允许访问您的开发机器(如本章前面所述),并确保您正确地复制和修改了连接字符串。

管理数据库种子

在清单12-15中,我在Startup类中注释掉了数据库播种语句。我这样做是因为上一节中用于将迁移应用到数据库的 Entity Framework Core 命令依赖于由 Startup 类设置的服务,这意味着,在启用了这些语句之后,将在应用迁移之前已经调用了数据库播种的代码,这将导致错误并阻止迁移工作。在建立数据库时,这并没有造成任何问题。对于产品数据库,这是因为SeedData.EnsurePopulated方法在播种数据之前应用迁移,并且直到我将迁移应用到数据库之后才向应用程序添加 Identity 种子数据。

对于生产环境,我希望对种子数据采取不同的方法。对于用户帐户,当尝试登录时,我将使用管理员帐户填充数据库。我将在管理工具中添加一个特性,用于产品数据库的播种,这样生产系统就可以填充用于测试数据的数据,或者在需要时保留实际数据的空白。

注意:生产系统中的身份验证数据播种应该小心执行,应用程序应该使用第28、29和30章中描述的功能来在应用程序部署后立即更改密码。

Identity 数据播种

更改用户数据播种方式的第一步是简化IdentitySeedData类中的代码,如清单12-16所示。

清单 12-16:Models 文件夹下的 IdentitySeedData.cs 文件,简化代码

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using System.Threading.Tasks;

namespace SportsStore.Models
{
    public static class IdentitySeedData
    {
        private const string adminUser = "Admin";
        private const string adminPassword = "Secret123$";
        public static async Task EnsurePopulated(UserManager<IdentityUser>
        userManager)
        {
            IdentityUser user = await userManager.FindByIdAsync(adminUser);
            if (user == null)
            {
                user = new IdentityUser("Admin");
                await userManager.CreateAsync(user, adminPassword);
            }
        }
    }
}

与其获得UserManager<IdentityUser>服务本身,不如将EnsurePopulated方法接收对象作为参数。这允许我在AccountController类中集成数据库种子,如清单12-17所示。

清单 12-17:Controllers 文件夹下的 AccountController.cs 文件,数据播种

using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using SportsStore.Models.ViewModels;
using SportsStore.Models;

namespace SportsStore.Controllers {
    [Authorize]
    public class AccountController : Controller {
        private UserManager<IdentityUser> userManager;
        private SignInManager<IdentityUser> signInManager;
        public AccountController(UserManager<IdentityUser> userMgr,
                SignInManager<IdentityUser> signInMgr) {
            userManager = userMgr;
            signInManager = signInMgr;
            IdentitySeedData.EnsurePopulated(userMgr).Wait();
        }
        // ...other methods omitted for brevity...
    }
}

这些更改将确保每次创建AccountController对象以处理 HTTP 请求时都会对 Identity 数据库进行播种处理。当然,这并不理想,但没有一种很好的方法来创建数据库,这种方法将确保应用程序可以在生产和开发中进行管理,尽管代价是一些额外的数据库查询。

产品数据播种

对于产品数据,我将向管理员提供一个按钮,该按钮将在数据库为空时为其注入种子。第一步是改变播种代码,让它使用一个接口,允许它访问控制器提供的服务,而不是通过Startup类,如清单12-18所示。我还注释了自动应用任何待定迁移的语句,这些迁移可能导致数据丢失,并且只应在生产系统中非常小心地使用。

清单 12-18:Models 文件夹下的 SeedData.cs 文件,准备手动播种

using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore;
using System;

namespace SportsStore.Models {
    public static class SeedData {
        public static void EnsurePopulated(IServiceProvider services) {
            ApplicationDbContext context =
                services.GetRequiredService<ApplicationDbContext>();
            //context.Database.Migrate();
            if (!context.Products.Any()) {
                context.Products.AddRange(
                // ...statements omiited for brevity...
                );
                context.SaveChanges();
            }
        }
    }
}

下一步是更新 Admin 控制器以添加将触发播种操作的 action 方法,如清单12-19所示。

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

namespace SportsStore.Controllers {
    [Authorize]
    public class AdminController : Controller {
        private IProductRepository repository;
        public AdminController(IProductRepository repo) {
            repository = repo;
        }
        public ViewResult Index() => View(repository.Products);

        // ...other methods omitted for brevity...

        [HttpPost]
        public IActionResult SeedDatabase() {
            SeedData.EnsurePopulated(HttpContext.RequestServices);
            return RedirectToAction(nameof(Index));
        }
    }
}

新的 action 用HttpPost特性修饰,以便可以用 POST 请求作为目标,并且一旦数据库被播种,它将将浏览器重定向到Index action 方法。剩下的就是创建一个用于数据库播种的按钮,在数据库为空时显示它,如清单12-20所示。

清单 12-20:Views/Admin 文件夹下的 Index.cshtml 文件,添加一个按钮

@model IEnumerable<Product>

@{
    ViewBag.Title = "All Products";
    Layout = "_AdminLayout";
}

@if (Model.Count() == 0)
{
    <div class="text-center m-2">
        <form asp-action="SeedDatabase" method="post">
            <button type="submit" class="btn btn-danger">Seed Database</button>
        </form>
    </div>
}
else
{
    <table class="table table-striped table-bordered table-sm">
        <tr>
            <th class="text-right">ID</th>
            <th>Name</th>
            <th class="text-right">Price</th>
            <th class="text-center">Actions</th>
        </tr>
        @foreach (var item in Model)
        {
            <tr>
                <td class="text-right">@item.ProductID</td>
                <td>@item.Name</td>
                <td class="text-right">@item.Price.ToString("c")</td>
                <td class="text-center">
                    <form asp-action="Delete" method="post">
                        <a asp-action="Edit" class="btn btn-sm btn-warning"
                           asp-route-productId="@item.ProductID">
                            Edit
                        </a>
                        <input type="hidden" name="ProductID" value="@item.ProductID" />
                        <button type="submit" class="btn btn-danger btn-sm">
                            Delete
                        </button>
                    </form>
                </td>
            </tr>
        }
    </table>
}
<div class="text-center">
    <a asp-action="Create" class="btn btn-primary">Add Product</a>
</div>

布署应用程序

要部署应用程序,右键单击【解决方案资源管理器】中的 SportsStore 项目(项目,而不是解决方案),然后从弹出菜单中选择【发布】。Visual Studio 将向您展示发布方法的选择,如图12-2所示。


如果部署失败,从哪里开始

导致部署失败的最大原因是连接字符串,要么是因为它们没有从 Azure 中正确复制,要么是因为它们插入用户名和密码时的编辑错误。如果部署失败,则连接字符串是开始的地方。如果您在《应用数据库迁移》这一节中没有从dotnet ef database update命令中获得预期的结果,那么您的部署将失败。如果命令确实有效,但部署失败,那么请确保设置了环境变量,因为您可能正在准备本地数据库,而不是云中的数据库。


图12-2 选择发布方法

选择【Microsoft Azure App Service】选项,并确保选择【Create New】(【Select Existing】选项用于更新现有部署的应用程序)。将提示您提供部署的详细信息。单击【Add an Account】并输入您的 Azure 证书。

输入凭证后,您可以为已部署的应用程序选择一个名称,并输入服务的详细信息,这将取决于您拥有的 Azure 帐户的类型、要部署到的区域以及您需要的部署服务,如图12-3所示。

图12-3 创建一个新的 Azure 应用程序服务

配置好服务后,单击【Create】按钮。一旦服务设置完毕,将提示您提供发布操作的摘要,该操作将应用程序发送到托管服务,如图12-4所示。

图12-4 服务发布摘要

单击【Publish】按钮开始发布进程。您可以通过从 Visual Studio 的【View】➤【Other Windows】菜单中选择【Web Publish Activity】来查看发布进度的详细信息。在此过程中要耐心等待,因为将项目中的所有文件发送到 Azure 服务可能需要一段时间。随后的更新将更快,因为只有修改过的文件才会被传输。

一旦部署完成,Visual Studio 将为已部署的应用程序打开一个新的浏览器窗口。由于产品数据库是空的,您将看到如图12-5所示的布局。

图12-5 已部署应用程序的初始状态

导航到 /Admin/Index URL 并使用用户名 Admin 和密码 **Secret123$**进行身份验证。Identity 数据库将按需运行,允许您登录应用程序的管理部分,如图12-6所示。

图12-6 管理屏幕

单击【Seed Database】按钮来填充产品数据库,这将产生如图12-7所示的结果。然后,您将导航到应用程序的根 URL 并正常使用它。

图12-7 填充数据库

总结

在本章和前几章中,我演示了如何使用 ASP.NET Core MVC 创建实际的电子商务应用程序。这个扩展示例引入了许多关键的 MVC 特性:控制器、action 方法、路由、视图、元数据、验证、布局、身份验证等等。您还看到了如何使用与 MVC 相关的一些关键技术,包括 Entity Framework Core、依赖注入和单元测试。其结果是一个应用程序具有一个干净的、面向组件的体系结构,它将各种关注点分离开来,并具有易于扩展和维护的代码库。在下一章中,我将向您展示如何使用 Visual Studio Code 来创建 ASP.NET Core MVC 应用程序。

;

© 2018 - IOT小分队文章发布系统 v0.3