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
现在我们已经可以给应用添加权限保护了,我们要保证某些类和方法只能被有授权的用户访问和使用。我们试着添加最基本的授权规则 [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
我们正确输入登录信息以后,就会自动跳转到 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
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
是一个框架,一个工具,我们不能直接通过理性逻辑思维把它运用纯熟,只能通过重复的方法。
祝你们早日精通!
发表回复