ASP.NET Core Identity 是一个会员系统,可让您为网络应用程序添加身份验证和授权功能。它为管理用户、角色、索赔和令牌提供了一个框架,并与 ASP.NET Core 应用程序无缝集成。下面概述了它的主要功能、组件和工作原理。

主要功能

  • 用户管理:创建、更新、删除和验证用户。存储用户信息,如用户名、密码、电子邮件地址和电话号码。
  • 角色管理:为用户定义和分配角色。使用角色管理对特定资源或功能的访问。
  • 基于申请的授权:将权利要求与用户关联起来,实现精细授权。请求可以代表权限、角色或其他与用户相关的信息。
  • 基于令牌的身份验证:生成并验证用于 API 身份验证的令牌(如 JWT 或刷新令牌)。
  • 密码管理:安全存储散列密码。提供密码策略(如复杂度、长度)。
  • 外部登录提供商:与谷歌、Facebook、微软或 Twitter 等外部身份验证系统集成。
  • 双因素身份验证(2FA):使用短信、电子邮件或验证器应用程序增加额外的安全层级。
  • 数据持久性:与 Entity Framework Core 协同工作,在数据库中持久保存用户相关数据。可进行定制,以适应不同的数据库提供商或模式。

创建 Identity 数据库

微软 SQL Server 和 Entity Framework Core 可以无缝结合,我们先安装 Entity Framework Core 的 Identity 软件包。
Listing 11-1 在项目文件夹的终端运行安装软件包命令

dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore --version 8.0.11

像前面的 EBook 数据库,我们也需要一个数据库上下文让应用可以访问 Identity 数据库。
Listing 11-2 在 Models 文件夹里新建一个 IdentityDbContext.cs 文件

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

namespace EBooksStore.Models;

public class IdDbContext(DbContextOptions<IdDbContext> options) : IdentityDbContext<IdentityUser>(options)
{
}

IdentityUser 是这个 Identity 框架的内建类,如果不需要其他复杂的功能,这个已经足足够用了。

为了让应用可以通过上下文访问数据库,也要在 appsettings.json 里定义一个连接字符串。
Listing 11-3 在项目文件夹的 appsettings.json 文件里增加连接字符串

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "EBooksStoreConnection": "Server=.,1433;Database=EBooksStore;User Id=sa;Password=P@ssword1234;MultipleActiveResultSets=false;TrustServerCertificate=true",
    "IdentityConnection": "Server=.,1433;Database=Identity;User Id=sa;Password=P@ssword1234;MultipleActiveResultSets=false;TrustServerCertificate=true"
  }
}

配置应用

我们需要在主程序里设置 Identity 数据库配置和 Identity 的中间件。
Listing 11-4 修改 Program.cs ,添加 Identity 配置和中间件

using EBooksStore.Models;
using EBooksStore.Models.Interfaces;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews();

builder.Services.AddDbContext<StoreDbContext>(options => {
    options.UseSqlServer(
        builder.Configuration["ConnectionStrings:EBooksStoreConnection"]);
});

//身份数据库连接和服务
builder.Services.AddDbContext<IdDbContext>(options => {
    options.UseSqlServer(
        builder.Configuration["ConnectionStrings:IdentityConnection"]);
});
builder.Services.AddIdentity<IdentityUser, IdentityRole>()
    .AddEntityFrameworkStores<IdDbContext>();

builder.Services.AddScoped<IStoreRepository, StoreRepository>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();

builder.Services.AddRazorPages();
//Add session services
builder.Services.AddDistributedMemoryCache();
builder.Services.AddSession(options => {
    options.Cookie.Name = ".EBooksStore.Session";
    options.IdleTimeout = TimeSpan.FromHours(30);
    options.Cookie.HttpOnly = true;
    options.Cookie.IsEssential = true;
});

builder.Services.AddServerSideBlazor();

var app = builder.Build();

// enable session middleware
app.UseSession();

//身份中间件
app.UseAuthentication();
app.UseAuthorization();

app.UseStaticFiles();
app.MapDefaultControllerRoute();

app.MapRazorPages();
app.MapBlazorHub();
app.MapFallbackToPage("/admin/{*catchall}", "/Admin/_Host");

// Seeds.InitPopulate(app);

app.Run();

UseAuthorization 和 UseAuthentication 中间件方法会完成身份验证处理机制。

创建和应用数据库迁移

和前面的类似,我们要创建数据库迁移和更新数据库。
Listing 11-5 在项目文件夹位置的终端执行命令

dotnet ef migrations add Initial --context IdDbContext
dotnet ef database update --context IdDbContext

这样就创建了一个 Identity 数据表,接着我们要给这个表填充初始数据。
Listing 11-6 在 Models 文件夹新建种子文件 IdSeeds.cs

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

namespace EBooksStore.Models;

public static class IdSeeds
{
    private const string adminUser = "admin";
    private const string adminPassword = "P@ssw0rd";

    public static async void InitPopulate(IApplicationBuilder app)
    {
        IdDbContext context = app.ApplicationServices
            .CreateScope().ServiceProvider.GetRequiredService<IdDbContext>();

        if (context.Database.GetPendingMigrations().Any())
        {
            context.Database.Migrate();
        }

        UserManager<IdentityUser> userManager = app.ApplicationServices
            .CreateScope().ServiceProvider.GetRequiredService<UserManager<IdentityUser>>();

        IdentityUser? user = await userManager.FindByIdAsync(adminUser);
        if (user == null)
        {
            user = new IdentityUser("admin")
            {
                Email = "admin@example.com",
                PhoneNumber = "1234567890"
            };
            await userManager.CreateAsync(user, adminPassword);
        }
    }
}

我们会经常用到 Identity 框架内建的 UserManager<T> 类,它是 ASP.NET Core Identity 提供的管理用户的服务。为了正常填充用户数据,我们还要在主程序 Program.cs 里添加运行这段代码的语句。
Listing 11-7 在 Program.cs 里添加种子填充语句

...

// Seeds.InitPopulate(app);
IdSeeds.InitPopulate(app); //填充 Identity 用户

app.Run();

这个应用的开发逻辑分开 2 个部分,前端部分我们用 MVC ,因为随着业务逻辑和需求不断细化,前端的复杂程度会提升得很快,MVC 比较适合做大型的有多层结构的应用。而 Razor Pages 是基于页面逻辑的编程模块,以完成快速简单的应用。对于后台管理,用 Razor Pages 应该比较合理。我们现在用 Identity 框架先做一个后台用户管理页面。Razor Pages 也遵循 MVC 的公约,所以我们也要有一个页面布局文件作为所有页面的框架。
Listing 11-8 在 Pages 文件夹新建 _RazorLayout.cshtml 文件

<!DOCTYPE html>
<html>

<head>
    <meta name="viewport" content="width=device-width" />
    <title>逻辑的电子书店</title>
    <link href="/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet" />
</head>

<body>
    <div class="bg-secondary text-white p-2">
        <span class="navbar-brand mx-2">逻辑的电子书店</span>
    </div>
    <div class="m-1 p-1">
        @RenderBody()
    </div>
</body>

</html>

把框架文件放进入口文件里。
Listing 11-9 在 Pages 文件夹里新建 _ViewStart.cshtml 文件

@{
    Layout = "_RazorLayout";
}

添加 Razor Pages 的引用。现在暂时引用 Mvc 的 RazorPages 命名空间。
Listing 11-10 在 Pages 文件夹里新建 _ViewImports.cshtml 文件

@using Microsoft.AspNetCore.Mvc.AddRazorPages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

我们先把用户列表显示看看。
Listing 11-10 在 Pages/Admin 文件夹里新建 StoreUsers.cshtml 文件

@page
@model UsersModel
@using Microsoft.AspNetCore.Identity

<h2 class="bg-dark text-white text-center p-3">管理用户</h2>
<table class="table table-striped">
    <thead>
        <tr>
            <th>用户名</th>
            <th>邮箱</th>
            <th>电话</th>
        </tr>
    </thead>
    <tbody>
        @foreach (var user in Model.Users)
        {
            <tr>
                <td>@user.UserName</td>
                <td>@user.Email</td>
                <td>@user.PhoneNumber</td>
            </tr>
        }
    </tbody>
</table>

@functions
{
    public class UsersModel : PageModel
    {
        private readonly UserManager<IdentityUser> _userManager;

        public UsersModel(UserManager<IdentityUser> userManager)
        {
            _userManager = userManager;
        }

        public IEnumerable<IdentityUser> Users { get; set; }

        public void OnGet()
        {
            Users = _userManager.Users;
        }
    }
}

这里我们显示所有注册的用户。运行应用,我们可以看到页面如图(我们目前只有一个用户)。

figure-11-1

Figure 11-1

现在我们已经可以给应用添加权限保护了,我们要保证某些类和方法只能被有授权的用户访问和使用。我们试着添加最基本的授权规则 [Authorize],真实的生产版要应用更有细节控制的规则。这个规则就是指明匿名用户不能访问。我们先给用户显示添加规则,这个当然是首先要保护的。
Listing 11-11 修改 Pages/Admin 文件夹里的 StoreUsers.cshtml 文件,添加授权规则

...
@using Microsoft.AspNetCore.Identity
@* 添加引用 *@
@using Microsoft.AspNetCore.Authorization

<h2 class="bg-dark text-white text-center p-3">管理用户</h2>
<table class="table table-striped">
...
@functions
{
    @* 应用授权规则 *@
    [Authorize]
    public class UsersModel : PageModel
...

接着给后台管理的主文件添加授权规则。
Listing 11-12 修改 Pages/Admin 文件夹里的 _Host.cshtml 文件,添加授权规则

@page "/admin"
@namespace EBooksStore.Pages.Admin
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

@using Microsoft.AspNetCore.Authorization
@attribute [Authorize]

<!DOCTYPE html>
<html>
...

现在匿名用户用浏览器访问这两个 Url 就会出现 404 错误,同时浏览器地址栏显示缺省的转向地址 http://localhost:5000/Account/Login?ReturnUrl=%2Fadmin%2Fstoreusers ,我们就按照这样的地址试着完成用户账户登录的逻辑。从这个地址可以看出,我们需要有一个 Account 控制器,并且里面至少要有一个 Login 方法。我们也可以把这样的公约推理到用 Razor Pages 实现同样的功能。

创建用户登录

我们在 Pages 文件里新建一个文件夹 Account ,再在 Account 文件夹新建一个 Login.cshtml Razor Page 文件。
Listing 11-13 在 Pages/Account 文件夹里新建一个 Login.cshtml 文件

@page
@model LoginModel
@using Microsoft.AspNetCore.Identity
@using System.ComponentModel.DataAnnotations

<h2 class="bg-primary text-white text-center">登录</h2>
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<form method="post">
    <div class="form-group">
        <label class="form-label">用户名</label>
        <input asp-for="InputName" class="form-control" />
        <span asp-validation-for="InputName" class="text-danger"></span>
    </div>
    <div class="form-group">
        <label class="form-label">密码</label>
        <input asp-for="InputPassword" type="password" class="form-control" />
        <span asp-validation-for="InputPassword" class="text-danger"></span>
    </div>
    <button type="submit" class="btn btn-primary my-3">登录</button>
</form>

@functions
{
    public class LoginModel : PageModel
    {
        private readonly SignInManager<IdentityUser> _signInManager;

        public LoginModel(SignInManager<IdentityUser> signInManager)
        {
            _signInManager = signInManager;
        }

        [BindProperty]
        [Required]
        public string InputName { get; set; } = string.Empty;

        [BindProperty]
        [Required]
        [DataType(DataType.Password)]
        public string InputPassword { get; set; } = string.Empty;

        public async Task<IActionResult> OnPostAsync(string returnUrl = "/")
        {
            if (ModelState.IsValid)
            {
                var result = await _signInManager.PasswordSignInAsync(InputName, InputPassword, false, lockoutOnFailure: false);
                if (result.Succeeded)
                {
                    return Redirect(returnUrl);
                }
                else
                {
                    ModelState.AddModelError(string.Empty, "邮箱或密码错误,登录失败");
                }
            }
            return Page();
        }
    }
}

我们把 returnUrl 默认设置为根目录,如果用户登录成功默认转到首页。
运行应用,在浏览器地址栏输入 http://localhost:5000/admin 。因为,我们给 admin 的 Index 设置了授权规则,所以浏览器会自动跳转到 Login 页面。

figure-11-2

Figure 11-2

我们正确输入登录信息以后,就会自动跳转到 admin 的 Index 页。
如果不关闭浏览器,那么关闭浏览器标签,打开新标签输入上面的地址,就可以直接到 Index 页面。浏览器保留了 Cookie 和 站点数据。这太不安全了吧?我们是自己开发测试,这个安全可以先不考虑吗?可以,但是,如果没有注销,我们会很麻烦,除非整个关闭浏览器,并退出浏览器进程;或者,手动清空 Cookie 和 站点数据,否则你会发现自己总是处于登录状态,不能正常测试。还是赶紧做一个注销(Logout)页比较方便。
Listing 11-14 在 Pages/Account 文件夹里新建一个 Logout.cshtml 文件

@page
@model LogoutModel
@using Microsoft.AspNetCore.Identity

<h2 class="bg-primary text-white text-center">注销</h2>
<div class="text-center">
    <form method="post">
        <button type="submit" class="btn btn-primary my-3">注销</button>
    </form>
</div>

@functions
{
    public class LogoutModel : PageModel
    {
        private readonly SignInManager<IdentityUser> _signInManager;

        public LogoutModel(SignInManager<IdentityUser> signInManager)
        {
            _signInManager = signInManager;
        }

        public async Task<IActionResult> OnPostAsync()
        {
            await _signInManager.SignOutAsync();
            return Redirect("/");
        }
    }
}

接着,我们给后台管理布局页面增加一个转到注销页面的按钮。
Listing 11-15 修改 Pages 文件夹的 _RazorLayout.cshtml

<!DOCTYPE html>
<html>

<head>
    <meta name="viewport" content="width=device-width" />
    <title>逻辑的电子书店</title>
    <link href="/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet" />
</head>

<body>
    <div class="bg-secondary text-white p-2 d-flex justify-content-between">
        <span class="navbar-brand mx-2">逻辑的电子书店</span>
        <span><a class="text-white" href="/account/logout">注销</a></span>
    </div>
    <div class="m-1 p-1">
        @RenderBody()
    </div>
</body>

</html>

到目前为止,我们离部署和发布我们的应用只有一步之遥了。但是,在部署之前,我们还要做一步本身比较繁杂,但是我们把它简化的重要的工作:错误处理。一个应用如果没有错误处理,一旦软件出问题,用户会不知所措,就需要我们给指明方向。

配置错误处理

应用出错的时候,我们最好提供一个相对友好的能提供有用信息的页面给用户。(我们在开发时出现的错误信息是不能暴露给最终用户的)我们在 Pages 文件夹创建这个文件。
Listing 11-16 在 Pages 文件夹里新建一个文件 Error.cshtml

@page "/error"
@{
    Layout = null;
}
<!DOCTYPE html>
<html>

<head>
    <meta name="viewport" content="width=device-width" />
    <link href="/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet" />
    <title>出现错误</title>
</head>

<body class="text-center">
    <h2 class="text-danger">应用处理请求出现错误!</h2>
    <div class="text-center">
        <a class="btn btn-primary" href="/">返回首页</a>
    </div>
</body>

</html>

我们还需要在主程序里指定在生产版里允许使用这个页面。
Listing 11-17 修改 Program.cs ,添加生产版错误页面处理

...
var app = builder.Build();

if(app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}
else
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

// enable session middleware
app.UseSession();
...

创建生产版设定 Json 文件

我们拷贝 appsettings.json 到同一文件夹里的新文件 appsettings.Production.json ,并删除所有信息,除了保留数据库连接字符串,并修改数据库名字,我们后面会用到这个名字。
Listing 11-18 appsettings.Production.json 内容

{
  "ConnectionStrings": {
    "EBooksStoreConnection": "Server=sqlserver,1433;Database=EBooksStore;User Id=sa;Password=P@ssword1234;MultipleActiveResultSets=false;TrustServerCertificate=true",
    "IdentityConnection": "Server=sqlserver,1433;Database=Identity;User Id=sa;Password=P@ssword1234;MultipleActiveResultSets=false;TrustServerCertificate=true"
  }
}

请注意:真实生产版不能用明文 Json 文件存储数据库连接字符串!根据具体服务器系统手册,选择安全的方式保存这些信息。

创建 Docker 镜像

我们计划用 Docker 来部署我们的应用,这是现在很流行的方式,被广泛的用于在微软 Azure 或者 亚马逊 AWS 部署应用。先用 docker --version 检查下 Docker 是否已经正确安装。

我们先发布应用。前面如果注释了那两条 Seeds 种子静态方法的,重新取消注释,因为我们的生产版会用新的数据库的,所有数据从头开始初始化。
Listing 11-18 在项目文件夹下执行终端命令

dotnet publish -c Release

在命令成功执行完毕时,请记下 DLL 文件名,我的环境里生产的文件名是 EBooksStore.dll 。还有,发布文件夹名字,我们的环境是 bin/Release/net8.0/publish/ 。这两个名字必须要正确反映在 Dockerfile 里,才能制作正确的应用镜像。
在我的环境里,他们长这样:

figure-11-3

Figure 11-3

Docker 配置文件是 Dockerfile ,没有后缀。我们在项目文件夹下新建 Dockerfile 并键入以下内容。
Listing 11-19 在项目文件夹下创建文件 Dockerfile

FROM mcr.microsoft.com/dotnet/aspnet:8.0
COPY /bin/Release/net8.0/publish/ EBooksStore/
ENV ASPNETCORE_ENVIRONMENT Production
ENV Logging__Console__FormatterName=Simple
EXPOSE 5000
WORKDIR /EBooksStore
ENTRYPOINT ["dotnet", "EbooksStore.dll", "--urls=http://0.0.0.0:5000"]

这个文件将指示如何创建应用镜像。因为我们的应用要依赖 MS SQL Server 容器,所以我们需要一个 docker-compose.yml 文件运行多个 Docker 服务。docker-compose.yml 允许我们在一个地方管理容器的环境变量、卷挂载和网络,以简化配置过程。
Listing 11-20 在项目文件夹下创建文件 docker-compose.yml

version: "3.9"
services:
  ebooksstore:
    image: ebooksstore:latest
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "5000:5000"
    environment:
      ASPNETCORE_ENVIRONMENT: Production
    depends_on:
      - sqlserver

  sqlserver:
    image: "mcr.microsoft.com/mssql/server:latest"
    ports:
      - "1433:1433"
    container_name: sqlserver_container
    environment:
      SA_PASSWORD: "P@ssword1234"
      ACCEPT_EULA: "Y"

注意 docker-compose 每行的缩进层级。
先看第一部分:包括应用名称,dockerfile ,端口设置正确后,后面一行 environment 表示我们要引用应用的 appsettings.Production.json 文件里的变量。接着是依赖数据库名称,和前面 json 文件里的保持一致。
第二部分就是设置数据库,简单明了,不多介绍了。

我们用 docker-compose 来 build 和运行 docker 组合。

docker-compose build

等待所有项目的进度条走完,会回到终端提示符,表示 build 好了。

运行容器化应用程序

我们先挂载和运行 sqlserver

docker-compose up sqlserver

运行时,终端会刷新很多信息条,注意到如果没有 Failure 后,打开新的终端,输入以下命令运行应用。

docker-compose up ebooksstore

如果上面过程中出错,我们可以用 Ctrl+C 终止终端的两个服务(sqlserver 和 ebooksstore) ,运行 docker-compse 的卸载命令。

docker-compose down

然后,修正应用程序或者 docker 文件的错误,接着最好运行 dotnet 清理命令:

dotnet clean

再按照前面的顺序:发布 -> docker-compose build -> docker-compose up … ,直到成功为止哦,千万不要放弃,第一次接触 docker 都会配到疑难的,万里长征最后一步,跨过去就是海阔天空。

到这里,我们的实战开发就结束了,相信大家都不会觉得很难。熟能生巧,我们建议大家不妨从头开始,多做几遍,加深领会。人类对待如何使用工具,从简单的,比如锯子,到复杂的,比如小提琴,都是用意识,下意识,肌肉反射等,经过重复训练,才能“从入门到精通”的。Asp.NET Core 是一个框架,一个工具,我们不能直接通过理性逻辑思维把它运用纯熟,只能通过重复的方法。
祝你们早日精通!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注