SPA^2 使用 ASP.NET Core 1.1 + Angular 2.4 - 第 4 部分





5.00/5 (31投票s)
使用 OpenIDDict 为我们的 ASP.NET Core + Angular 2 SPA 添加 JSON Web 令牌身份验证 (JWT)。源代码包括 VS2015 和 VS2017 版本。
引言
我写这些文章的目的是展示另一种使用 ASP.NET Core 和 Angular 2 创建 SPA 的方法;本文前 3 部分概述的这种技术允许您保留各自的最佳功能,简化开发和项目中不可避免的更改,消除复制代码的诱惑,同时保持相对简单。通常,结合使用 ASP.NET Core 和 Angular 2 的应用程序会变成一个复杂的代码网络,包含服务器端预渲染、webpack 和大量的中间件“胶水”,或者走向另一个极端,将它们视为使用“扁平”HTML 模板的完全独立的岛屿。
ASP.NET Core 使用部分视图来提供更智能的 Angular 2 模板。由于这些 Angular 2 模板是由标签助手生成的,并利用了您的 C# 数据模型,因此它们可以加速您的 Angular 开发。由于标签助手由您的数据模型驱动,它们减少了重复且易出错的手动编码,确保您的客户端代码自动生成(使其更容易更新样式/类、描述或添加工具提示),所有这些都让您有更多时间来处理您应用程序中真正重要的部分。
- 第 1 部分 - 如何集成 ASP.NET Core 和 Angular 2
- 第 2 部分 - 如何使用标签助手显示数据
- 第 3 部分 - 如何将标签助手用于数据输入,添加使用 EF Core 的 SQL 后端。
- 第 4 部分 - 使用
OpenIdDict
添加基于 JWT 的令牌身份验证(本文)
背景
虽然安全性不是本系列文章的主要目标,但它是我们构建的大多数 Web 应用程序的重要功能。本文(第 4 部分)将涵盖此主题。同时,您还将看到一个示例,说明我们的简单标签助手如何使数据输入和验证更加容易。
基于令牌的安全性是保护现代应用程序最常用的方法之一。我们将使用 JWT 令牌 添加身份验证,使用 OpenIdDict 包,作者是 Kévin Chalet。
我选择 OpenIdDict
作为本文使用的身份验证库,因为它相对易于使用、“轻量级”、免费,并且有源代码和示例代码可用。您可以查看代码和示例,深入了解令牌身份验证。
有许多其他选择,包括免费增值“云”产品 Auth0 或您可以自行托管的 Identity Server 4。您的选择将取决于许多因素;您需要的支持级别(商业支持 vs 开源),将有哪些客户端连接到您的应用程序(Web、移动、桌面、内部、不同域/第三方)。涵盖所有这些考虑因素很容易填满一个关于该主题的完整系列。有关令牌验证的更多信息,请参阅 Kevin Chalet 的教程。
更新 2017 年 3 月 2 日:如果您是第一次阅读,可以忽略此部分,直接跳到“使用 OpenIdDict 添加身份验证”。
另一方面,如果您已经开始使用此代码,并且遇到了问题,我刚刚发现最新的 OpenIdDict
包(版本 1.0.0-rc1-1093)现在需要一些尚未进入 OpenIdDict
示例的更改。如果您仍在使用版本 1.0.0-rc1-1077(通过查看 project.lock.json 文件来判断),您可以强制出现错误,并更新到新版本,方法是删除 project.lock.json 文件,然后重新构建应用程序,这将下载最新的包,然后导致登录时出现问题。
通常,错误是服务器端异常或 500 错误,显示类似内容:
处理请求时发生未处理的异常
InvalidOperationException:由于身份验证票证不包含强制的主题声明,因此被拒绝。
当前文章的此版本(下方)已更新,以支持新版本的 OpenIdDict
。这些更改也已在 github 存储库的 part4 分支中完成。
第一个更改在 startup.cs 中,您需要从以下内容更改为:
// to replace the default OpenIddict entities.
options.UseOpenIddict();
});
// Register the Identity services.
services.AddIdentity<ApplicationUser, IdentityRole>()
.AddEntityFrameworkStores<A2spaContext>()
.AddDefaultTokenProviders();
// Register the OpenIddict services.
services.AddOpenIddict()
// Register the Entity Framework stores.
改为这样。
// to replace the default OpenIddict entities.
options.UseOpenIddict();
});
// Register the Identity services.
services.AddIdentity<ApplicationUser, IdentityRole>()
.AddEntityFrameworkStores<A2spaContext>()
.AddDefaultTokenProviders();
// Configure Identity to use the same JWT claims as OpenIddict instead
// of the legacy WS-Federation claims it uses by default (ClaimTypes),
// which saves you from doing the mapping in your authorization controller.
services.Configure<IdentityOptions>(options =>
{
options.ClaimsIdentity.UserNameClaimType = OpenIdConnectConstants.Claims.Name;
options.ClaimsIdentity.UserIdClaimType = OpenIdConnectConstants.Claims.Subject;
//options.ClaimsIdentity.RoleClaimType = OpenIdConnectConstants.Claims.Role;
});
// Register the OpenIddict services.
services.AddOpenIddict()
// Register the Entity Framework stores.
添加的声明的第 3 行被注释掉了,因为 enum .Role
尚未在 OpenIdDict
包中支持。
提醒:OpenIdDict 是一个 Beta 包,如果您想要稳定性,可以考虑 Auth0 或等到 Identity Server 4 或 OpenIdDict 正式发布。如果您现在处于开发阶段,并且愿意承担重大更改,也许可以继续使用 OpenIdDict。这取决于您和您的风险水平+时间框架。
下一个小更改是在您的 startup.cs using
s 中添加此内容:
using AspNet.Security.OpenIdConnect.Primitives;
最后更改,将 project.json 从以下内容更新为:
"Microsoft.AspNetCore.Identity": "1.1.0",
"AspNet.Security.OAuth.Introspection": "1.0.0-beta1-0201",
"Microsoft.AspNetCore.Identity.EntityFrameworkCore": "1.1.0",
"AspNet.Security.OAuth.Validation": "1.0.0-beta1-0201"
},
改为这样。
"Microsoft.AspNetCore.Identity": "1.1.0",
"AspNet.Security.OAuth.Introspection": "1.0.0-beta1-*",
"Microsoft.AspNetCore.Identity.EntityFrameworkCore": "1.1.0",
"AspNet.Security.OAuth.Validation": "1.0.0-beta1-*"
},
此时,删除您的 project.lock.json 文件并重新构建,按 Ctrl-F5,登录应该可以再次工作。
(更新结束)
使用 OpenIdDict 添加身份验证
OpenIDDict
主要通过 Kévin Chalet 在 GitHub 上提供,(用他的话说)它基于 AspNet.Security.OpenIdConnect.Server (代号 ASOS) ... 它可以控制 OpenID Connect 身份验证流程,并可与任何成员资格堆栈一起使用,包括 ASP.NET Core Identity。
我们将使用“密码流”,其中最终用户输入用户名和密码,这些用户名和密码会使用 Entity Framework Core 根据 SQL 数据库(可以是几乎任何数据库)进行验证。
如果有效,将生成一个加密的“令牌”,并在服务器响应中传递回客户端网页。
一旦客户端通过此验证阶段,就不再需要重新验证用户名或密码,因为“令牌”用于客户端证明其身份(身份验证)和允许的操作(授权)。令牌嵌入了过期时间,以及其他加密数据,有助于确保令牌未被篡改。
当浏览器接收到此令牌时,Angular 2 代码将其存储在浏览器的会话存储中。如果您希望实现浏览器范围内的验证,可以更新代码以使用本地存储而不是会话存储。然后,此令牌会随每个服务器请求一起在请求标头中发送,然后在服务器上验证令牌的完整性,并与用户凭据和角色进行匹配。关于使用本地存储/会话存储与使用 Cookie 的争论超出了本文的范围,如果您愿意,请在其他地方进行研究。需要注意的是,如果您的最终用户禁用了 Cookie,此处所述的令牌验证仍然有效,因为它不受 Cookie 是否启用影响。
我主要遵循 OpenIdDict
示例中的代码,用于 PasswordFlow:
https://github.com/openiddict/openiddict-samples/tree/8032ef4b63c97bb26af0785ed1b317e7cc2b1247/samples
更新于 2021 年 4 月 22 日 注意:上面的链接来自 2018 年 9 月 30 日的较早提交。
您可以克隆此项目和 OpenIDDict Core 包作为参考,以帮助理解其工作原理。
我们代码的第一个修改来自第 3 部分,它将在 /ViewModels 文件夹中。我们需要添加一个名为 Accounts 的新文件夹,其中我们将添加两个新的类文件来描述我们将用于新用户注册和登录的数据模型。首先,创建 LoginViewModel.cs 并将其编辑为:
using System.ComponentModel.DataAnnotations;
namespace A2SPA.ViewModels.Account
{
public class LoginViewModel
{
[Required, RegularExpression(@"([a-zA-Z0-9_\-\.]+)@
((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.)|(([a-zA-Z0-9\-]+\.)+))
([a-zA-Z]{2,4}|[0-9]{1,3})", ErrorMessage = "Please enter a valid email address.")]
[EmailAddress]
[Display(Name = "Username", ShortName = "Email", Prompt = "Email Address (username)")]
[DataType(DataType.EmailAddress)]
public string Email { get; set; }
[Required]
[StringLength(100, ErrorMessage =
"The {0} must be at least {2} characters long.", MinimumLength = 6)]
[DataType(DataType.Password)]
[Display(Name = "Password")]
public string Password { get; set; }
}
}
接下来创建 RegisterViewModel.cs,它将继承我们 Login View Model 的一些属性;将其编辑为:
using System.ComponentModel.DataAnnotations;
namespace A2SPA.ViewModels.Account
{
public class RegisterViewModel : LoginViewModel
{
[Required]
[StringLength(100, ErrorMessage =
"The {0} must be at least {2} characters long.", MinimumLength = 6)]
[DataType(DataType.Password)]
[Display(Name = "Password Verification")]
public string VerifyPassword { get; set; }
}
}
接下来,我们将添加一些新的程序包,通过 NuGet 进行依赖项管理,有些通过 MyGet。
确保您更新了程序包源以包含 MyGet 源,使用密钥 aspnet-contrib
和地址:
https://www.myget.org/F/aspnet-contrib/api/v3/index.json
您可以在 Visual Studio 2015 中执行此操作,它将全局使用;或者,您可以为此解决方案编辑 nuget.config 文件。您可以直接编辑 nuget.config,它应该位于解决方案文件旁边。
如果不存在,请创建一个。它应该包含以下内容:
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="NuGet" value="https://api.nuget.org/v3/index.json" />
<add key="aspnet-contrib"
value="https://www.myget.org/F/aspnet-contrib/api/v3/index.json" />
</packageSources>
</configuration>
以前,我们使用过 NuGet 包管理器(仍然可以使用),但为了演示另一种方法,我们将通过直接编辑 package.json 文件来添加这些。这些添加项应放在 dependencies 部分,如果放在该部分的末尾,请确保在前一行添加了逗号:
"OpenIddict": "1.0.0-*",
"OpenIddict.EntityFrameworkCore": "1.0.0-*",
"OpenIddict.Mvc": "1.0.0-*",
"Microsoft.AspNetCore.Identity": "1.1.0",
"AspNet.Security.OAuth.Introspection": "1.0.0-beta1-*",
"Microsoft.AspNetCore.Identity.EntityFrameworkCore": "1.1.0",
"AspNet.Security.OAuth.Validation": "1.0.0-beta1-*"
顺便说一句,从 VS2017 开始,我们将丢失 package.json,并恢复更“传统”的项目文件,其中将包含这些依赖项。
接下来,我们将编辑 startup.cs 文件以添加对 OpenIDDict
的支持。这包括添加几个新的依赖项以访问 ApplicationUser
和 IdentityRoles
,以及对文件进行一些小的更改以设置到 OpenIdDict
网站服务的路由。由于更改分散在整个文件中,为避免出错,我已将完整的 startup.cs(包含更改)放在下面。要查看修改了什么,可以在 Git 历史记录中进行比较。
以下是 startup.cs 的完整新版本:
using A2SPA.Data;
using A2SPA.Models;
using AspNet.Security.OpenIdConnect.Primitives;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging;
using System.IO;
namespace A2SPA
{
public class Startup
{
public Startup(IHostingEnvironment env)
{
var builder = new ConfigurationBuilder()
.SetBasePath(env.ContentRootPath)
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true)
.AddEnvironmentVariables();
Configuration = builder.Build();
}
public IConfigurationRoot Configuration { get; }
// This method gets called by the runtime.
// Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
// Add framework services.
services.AddMvc();
services.AddDbContext<A2spaContext>(options =>
{
options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection"));
// Register the entity sets needed by OpenIddict.
// Note: use the generic overload if you need
// to replace the default OpenIddict entities.
options.UseOpenIddict();
});
// Register the Identity services.
services.AddIdentity<ApplicationUser, IdentityRole>()
.AddEntityFrameworkStores<A2spaContext>()
.AddDefaultTokenProviders();
// Configure Identity to use the same JWT claims as OpenIddict instead
// of the legacy WS-Federation claims it uses by default (ClaimTypes),
// which saves you from doing the mapping in your authorization controller.
services.Configure<IdentityOptions>(options =>
{
options.ClaimsIdentity.UserNameClaimType = OpenIdConnectConstants.Claims.Name;
options.ClaimsIdentity.UserIdClaimType = OpenIdConnectConstants.Claims.Subject;
//options.ClaimsIdentity.RoleClaimType = OpenIdConnectConstants.Claims.Role;
});
// Register the OpenIddict services.
services.AddOpenIddict()
// Register the Entity Framework stores.
.AddEntityFrameworkCoreStores<A2spaContext>()
// Register the ASP.NET Core MVC binder used by OpenIddict.
// Note: If you don't call this method, you won't be able to
// bind OpenIdConnectRequest or OpenIdConnectResponse parameters.
.AddMvcBinders()
// Enable the token endpoint.
.EnableTokenEndpoint("/connect/token")
// Enable the password flow.
.AllowPasswordFlow()
// During development, you can disable the HTTPS requirement.
.DisableHttpsRequirement();
}
// This method gets called by the runtime.
// Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env,
ILoggerFactory loggerFactory, A2spaContext context)
{
loggerFactory.AddConsole(Configuration.GetSection("Logging"));
loggerFactory.AddDebug();
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseBrowserLink();
}
else
{
app.UseExceptionHandler("/Home/Error");
}
// Add a middleware used to validate access
// tokens and protect the API endpoints.
app.UseOAuthValidation();
// Alternatively, you can also use the introspection middleware.
// Using it is recommended if your resource server is in a
// different application/separated from the authorization server.
//
// app.UseOAuthIntrospection(options =>
// {
// options.AutomaticAuthenticate = true;
// options.AutomaticChallenge = true;
// options.Authority = "https://:58795/";
// options.Audiences.Add("resource_server");
// options.ClientId = "resource_server";
// options.ClientSecret = "875sqd4s5d748z78z7ds1ff8zz8814ff88ed8ea4z4zzd";
// });
app.UseOpenIddict();
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider
(Path.Combine(env.ContentRootPath, "node_modules")),
RequestPath = "/node_modules"
});
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}");
// in case multiple SPAs required.
routes.MapSpaFallbackRoute("spa-fallback",
new { controller = "home", action = "index" });
});
if (env.IsDevelopment())
{
DbInitializer.Initialize(context);
}
}
}
}
接下来,在 /Views/Shared 文件夹中添加一个名为 _LoginPartial.cshtml 的新部分视图。
可以将其中的代码直接添加到 AppComponent.cshtml 中,但在此处将其分开演示了服务器端共享部分视图的另一种用法,其中视图可以包含其他部分视图。此技术可用于简化大量服务器端代码,也可减少重复,因为它允许在其他视图页面中轻松重用。
应将此新的部分视图 _LoginPartial.cshtml 编辑为包含以下内容:
<div [hidden]="!isLoggedIn()">
<ul class="nav navbar-nav navbar-right">
<li>
<a class="nav-link" (click)="logout()" routerLink="/home">Logout</a>
</li>
</ul>
</div>
<div [hidden]="isLoggedIn()">
<ul class="nav navbar-nav navbar-right">
<li><a class="nav-link" (click)="setTitle('Register - A2SPA')"
routerLink="/register">Register</a></li>
<li><a class="nav-link" (click)="setTitle('Login - A2SPA')"
routerLink="/login">Login</a></li>
</ul>
</div>
要使用这个新的共享部分视图,请在视图 AppComponent.cshtml 中按如下所示引用它。此简短代码块仅显示 AppComponent.cshtml 视图中新更新的菜单部分:
<div class="navbar-collapse collapse">
<ul class="nav navbar-nav">
<li>
<a class="nav-link" (click)="setTitle('Home - A2SPA')"
routerLink="/home" routerLinkActive="active">Home</a>
</li>
<li [hidden]="!isLoggedIn()">
<a class="nav-link" (click)="setTitle('About - A2SPA')"
routerLink="/about">About</a>
</li>
<li>
<a class="nav-link" (click)="setTitle('Contact - A2SPA')"
routerLink="/contact">Contact</a>
</li>
</ul>
@await Html.PartialAsync("_LoginPartial")
</div>
您会注意到菜单中的其他一些更改。很快,我们将无法访问原始的 About 页面,除非登录。我试图让菜单与现成的 VS2015 ASP.NET Core 模板保持相似。
接下来,我们将添加两个新视图;一个用于新用户注册,另一个用于已注册用户的登录页面。
在 /Views/Partial 文件夹中,添加一个名为 RegisterComponent.cshtml 的新视图,它将用作 MVC 部分视图,就像我们现有的 About 或 Contact 视图一样。将此新视图编辑为包含以下内容:
@using A2SPA.ViewModels
@using A2SPA.ViewModels.Account
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *,A2SPA
@model RegisterViewModel
<div class="jumbotron center-block">
<h2>Register</h2>
<form role="form" #testForm="ngForm">
<div *ngIf="registerViewModel != null">
<tag-di for="Email"></tag-di>
<tag-di for="Password"></tag-di>
<tag-di for="VerifyPassword"></tag-di>
<button type="button" (click)="register($event)"
class="btn btn-default">Submit</button>
<span class="small">Already registered?
<a [routerLink]="['/login']"> Click here to Login</a></span>
</div>
</form>
</div>
<div *ngIf="errorMessage != null">
<p>Error:</p>
<pre>{{ errorMessage }}</pre>
</div>
接下来,同样在 /Views/Partial 文件夹中,添加一个名为 LoginComponent.cshtml 的新部分视图,并将其编辑为包含以下内容:
@using A2SPA.ViewModels
@using A2SPA.ViewModels.Account
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *,A2SPA
@model LoginViewModel
<div class="jumbotron center-block">
<h2>Login</h2>
<form role="form" #testForm="ngForm">
<div *ngIf="loginViewModel != null">
<tag-di for="Email"></tag-di>
<tag-di for="Password"></tag-di>
<button type="button" (click)="login($event)"
class="btn btn-default">Submit</button>
<span class="small">Not registered?
<a [routerLink]="['/register']"> Click here to Register</a></span>
</div>
</form>
</div>
<div *ngIf="errorMessage != null">
<p>Error:</p>
<pre>{{ errorMessage }}</pre>
</div>
由于有了标签助手,这两个视图都比它们原本可能更简短!
为了允许访问这两个新的部分视图,请编辑 /Controllers/PartialController.cs 以添加以下行:
public IActionResult LoginComponent() => PartialView();
public IActionResult RegisterComponent() => PartialView();
接下来,在项目根目录(与 /ViewModels 并列)创建一个 /Models 文件夹。在此文件夹中,创建一个名为 ApplicationUser.cs 的新类 - 应将其编辑为包含以下代码:
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
namespace A2SPA.Models
{
// Add profile data for application users by adding properties to the ApplicationUser class
public class ApplicationUser : IdentityUser { }
}
正如注释所示,您可以扩展此 ApplicationUser
类来支持更复杂的用户配置文件,只需在此处添加属性即可。
接下来,我们将编辑上下文类 /data/A2spaContext.cs 以支持 OpenIdDict
所需的表。这将使 A2spaContext
继承自 IdentityDbContext<ApplicationUser>
而不是 DbContext
,然后我们在最后调用基类来从数据模型构建我们的数据库。
为清晰起见,以下是 A2spaContext.cs 文件中代码的完整新版本:
using A2SPA.Models;
using A2SPA.ViewModels;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace A2SPA.Data
{
public class A2spaContext : IdentityDbContext<ApplicationUser>
{
public A2spaContext(DbContextOptions<A2spaContext> options) : base(options)
{
}
public DbSet<TestData> TestData { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<TestData>().ToTable("TestData");
base.OnModelCreating(modelBuilder);
}
}
}
我们需要更新我们现有的 SampleDataController.cs 文件,以确保它不再对匿名用户可用,并且只能供已验证用户使用。添加此 using
语句:
using Microsoft.AspNetCore.Authorization;
然后在此处添加 [Authorize]
属性,如下所示:
namespace A2SPA.Api
{
[Authorize]
[Route("api/[controller]")]
public class SampleDataController : Controller
如果您愿意,可以使用 [Anonymous]
属性开放一两个方法,但此简单添加将确保控制器中的所有方法都对未登录用户不可用。
后端更改几乎完成,现在我们需要添加新的 Web API 控制器来支持新用户注册以及登录和注销方法。首先,在我们的 /Api 文件夹中添加新的类文件 AccountController.cs,然后将其编辑为包含以下内容:
using A2SPA.Data;
using A2SPA.Models;
using A2SPA.ViewModels.Account;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using System.Threading.Tasks;
namespace A2SPA.Api
{
[Authorize]
public class AccountController : Controller
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly A2spaContext _context;
private static bool _databaseChecked;
public AccountController(UserManager<ApplicationUser> userManager,
A2spaContext applicationDbContext)
{
_userManager = userManager;
_context = applicationDbContext;
}
//
// POST: /Account/Register
[HttpPost]
[AllowAnonymous]
public async Task<IActionResult> Register([FromBody] RegisterViewModel model)
{
if (ModelState.IsValid)
{
var user = new ApplicationUser
{ UserName = model.Email, Email = model.Email };
var result = await _userManager.CreateAsync(user, model.Password);
if (result.Succeeded)
{
return Ok();
}
AddErrors(result);
}
// If we got this far, something failed.
return BadRequest(ModelState);
}
#region Helpers
private void AddErrors(IdentityResult result)
{
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
}
#endregion
}
}
上面的 AccountController
已从原始版本修改,以在此处删除数据库生成,因为我们已将其集成到 startup.cs 中,用于我们的示例数据。当然,您可以维护两个不同的数据上下文;一个仅用于数据,另一个用于身份验证。有关此以及其他 OpenIdDict 特定类和代码块的更多信息,请再次参考 OpenIdDict core 和 示例源。
下一个新控制器也位于 /Api 文件夹中,应命名为 AuthorizationController.cs,需要更改为:
/*
* Licensed under the Apache License, Version 2.0
* (https://apache.ac.cn/licenses/LICENSE-2.0)
* See https://github.com/openiddict/openiddict-core for more information concerning
* the license and the contributors participating to this project.
*/
using A2SPA.Models;
using AspNet.Security.OpenIdConnect.Extensions;
using AspNet.Security.OpenIdConnect.Primitives;
using AspNet.Security.OpenIdConnect.Server;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Core;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
namespace A2SPA.Api
{
public class AuthorizationController : Controller
{
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly UserManager<ApplicationUser> _userManager;
public AuthorizationController(
SignInManager<ApplicationUser> signInManager,
UserManager<ApplicationUser> userManager)
{
_signInManager = signInManager;
_userManager = userManager;
}
[HttpPost("~/connect/token"), Produces("application/json")]
public async Task<IActionResult> Exchange(OpenIdConnectRequest request)
{
Debug.Assert(request.IsTokenRequest(),
"The OpenIddict binder for ASP.NET Core MVC is not registered. " +
"Make sure services.AddOpenIddict().AddMvcBinders() is correctly called.");
if (request.IsPasswordGrantType())
{
var user = await _userManager.FindByNameAsync(request.Username);
if (user == null)
{
return BadRequest(new OpenIdConnectResponse
{
Error = OpenIdConnectConstants.Errors.InvalidGrant,
ErrorDescription = "The username/password couple is invalid."
});
}
// Ensure the user is allowed to sign in.
if (!await _signInManager.CanSignInAsync(user))
{
return BadRequest(new OpenIdConnectResponse
{
Error = OpenIdConnectConstants.Errors.InvalidGrant,
ErrorDescription = "The specified user is not allowed to sign in."
});
}
// Reject the token request if two-factor authentication
// has been enabled by the user.
if (_userManager.SupportsUserTwoFactor &&
await _userManager.GetTwoFactorEnabledAsync(user))
{
return BadRequest(new OpenIdConnectResponse
{
Error = OpenIdConnectConstants.Errors.InvalidGrant,
ErrorDescription = "The specified user is not allowed to sign in."
});
}
// Ensure the user is not already locked out.
if (_userManager.SupportsUserLockout &&
await _userManager.IsLockedOutAsync(user))
{
return BadRequest(new OpenIdConnectResponse
{
Error = OpenIdConnectConstants.Errors.InvalidGrant,
ErrorDescription = "The username/password couple is invalid."
});
}
// Ensure the password is valid.
if (!await _userManager.CheckPasswordAsync(user, request.Password))
{
if (_userManager.SupportsUserLockout)
{
await _userManager.AccessFailedAsync(user);
}
return BadRequest(new OpenIdConnectResponse
{
Error = OpenIdConnectConstants.Errors.InvalidGrant,
ErrorDescription = "The username/password couple is invalid."
});
}
if (_userManager.SupportsUserLockout)
{
await _userManager.ResetAccessFailedCountAsync(user);
}
// Create a new authentication ticket.
var ticket = await CreateTicketAsync(request, user);
return SignIn(ticket.Principal, ticket.Properties, ticket.AuthenticationScheme);
}
return BadRequest(new OpenIdConnectResponse
{
Error = OpenIdConnectConstants.Errors.UnsupportedGrantType,
ErrorDescription = "The specified grant type is not supported."
});
}
private async Task<AuthenticationTicket> CreateTicketAsync
(OpenIdConnectRequest request, ApplicationUser user)
{
// Create a new ClaimsPrincipal containing the claims that
// will be used to create an id_token, a token or a code.
var principal = await _signInManager.CreateUserPrincipalAsync(user);
// Note: by default, claims are NOT automatically
// included in the access and identity tokens.
// To allow OpenIddict to serialize them,
// you must attach them a destination, that specifies
// whether they should be included in access tokens, in identity tokens or in both.
foreach (var claim in principal.Claims)
{
// In this sample, every claim is serialized in both
// the access and the identity tokens.
// In a real world application, you'd probably want to exclude
// confidential claims or apply a claims policy based on
// the scopes requested by the client application.
claim.SetDestinations(OpenIdConnectConstants.Destinations.AccessToken,
OpenIdConnectConstants.Destinations.IdentityToken);
}
// Create a new authentication ticket holding the user identity.
var ticket = new AuthenticationTicket(
principal, new AuthenticationProperties(),
OpenIdConnectServerDefaults.AuthenticationScheme);
// Set the list of scopes granted to the client application.
// Note: the offline_access scope must be granted
// to allow OpenIddict to return a refresh token.
ticket.SetScopes(new[]
{
OpenIdConnectConstants.Scopes.OpenId,
OpenIdConnectConstants.Scopes.Email,
OpenIdConnectConstants.Scopes.Profile,
OpenIdConnectConstants.Scopes.OfflineAccess,
OpenIddictConstants.Scopes.Roles
}.Intersect(request.GetScopes()));
return ticket;
}
}
}
最后一个,也位于 /Api 文件夹中,应命名为 ResourceController.cs 并编辑为包含:
using A2SPA.Models;
using AspNet.Security.OAuth.Validation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using System.Threading.Tasks;
namespace A2SPA.Api
{
[Route("api")]
public class ResourceController : Controller
{
private readonly UserManager<ApplicationUser> _userManager;
public ResourceController(UserManager<ApplicationUser> userManager)
{
_userManager = userManager;
}
[Authorize(ActiveAuthenticationSchemes = OAuthValidationDefaults.AuthenticationScheme)]
[HttpGet("message")]
public async Task<IActionResult> GetMessage()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return BadRequest();
}
return Content($"{user.UserName} has been successfully authenticated.");
}
}
}
接下来,我们将注意力转移到支持此功能所需的前端更改。
Angular 2 更改以支持身份验证
在 /wwwroot/app/models 文件夹中,我们将创建两个新的 TypeScript 文件来镜像新的登录视图模型和注册视图模型。
首先,在 /wwwroot/app/models 中创建 LoginViewModel.ts 并将其编辑为:
import { Component } from '@angular/core';
export class LoginViewModel {
email: string;
password: string;
}
接下来,在 /wwwroot/app/models 中创建 RegisterViewModel.ts 并将其编辑为:
import { Component } from '@angular/core';
export class RegisterViewModel {
email: string;
password: string;
verifyPassword: string;
}
接下来,在 ./models 旁边创建一个新文件夹 ./security,即 /wwwroot/app/security,并在此文件夹中添加一个名为 auth.service.ts 的新 TypeScript 文件,其中将包含许多有用的方法来构建我们可以添加到请求中的请求标头,以及在我们成功登录时将我们的令牌保存到
sessionstorage
。
将此新文件 /app/security/auth.service.ts 编辑为:
import { Component } from '@angular/core';
import { Injectable } from '@angular/core';
import { Headers } from '@angular/http';
import { OpenIdDictToken } from './OpenIdDictToken'
@Injectable()
export class AuthService {
constructor() { }
// for requesting secure data using json
authJsonHeaders() {
let header = new Headers();
header.append('Content-Type', 'application/json');
header.append('Accept', 'application/json');
header.append('Authorization', 'Bearer ' + sessionStorage.getItem('bearer_token'));
return header;
}
// for requesting secure data from a form post
authFormHeaders() {
let header = new Headers();
header.append('Content-Type', 'application/x-www-form-urlencoded');
header.append('Accept', 'application/json');
header.append('Authorization', 'Bearer ' + sessionStorage.getItem('bearer_token'));
return header;
}
// for requesting unsecured data using json
jsonHeaders() {
let header = new Headers();
header.append('Content-Type', 'application/json');
header.append('Accept', 'application/json');
return header;
}
// for requesting unsecured data using form post
contentHeaders() {
let header = new Headers();
header.append('Content-Type', 'application/x-www-form-urlencoded');
header.append('Accept', 'application/json');
return header;
}
// After a successful login, save token data into session storage
// note: use "localStorage" for persistent,
// browser-wide logins; "sessionStorage" for per-session storage.
login(responseData: OpenIdDictToken) {
let access_token: string = responseData.access_token;
let expires_in: number = responseData.expires_in;
sessionStorage.setItem('access_token', access_token);
sessionStorage.setItem('bearer_token', access_token);
// TODO: implement meaningful refresh, handle expiry
sessionStorage.setItem('expires_in', expires_in.toString());
}
// called when logging out user; clears tokens from browser
logout() {
//localStorage.removeItem('access_token');
sessionStorage.removeItem('access_token');
sessionStorage.removeItem('bearer_token');
sessionStorage.removeItem('expires_in');
}
// simple check of logged in status: if there is a token, we're (probably) logged in.
// ideally we check status and check token has not expired
// (server will back us up, if this not done, but it could be cleaner)
loggedIn() {
return !!sessionStorage.getItem('bearer_token');
}
}
下一个新文件应命名为 auth-guard.service.ts,并添加到同一文件夹 /wwwroot/app/security 中,并应包含:
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { CanActivate } from '@angular/router';
import { AuthService } from './auth.service';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private authService: AuthService, private router: Router) { }
canActivate() {
if (!this.authService.loggedIn()) {
this.router.navigate(['/login']);
return false;
}
return true;
}
}
最后,我们将添加最后一个 TypeScript 文件,一个数据模型,到 /wwwroot/app/security
文件夹。应将其命名为 OpenIdDictToken.ts 并包含以下内容:
import { Component } from '@angular/core';
export class OpenIdDictToken {
access_token: string;
expires_in: number;
refresh_token: string;
token_type: string;
}
在 /wwwroot/app 目录中,接下来我们将添加几个新的 TypeScript 组件。首先,创建
register.component.ts,它将处理新用户注册,应包含以下内容:
import { Component, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { Http } from '@angular/http';
import { AuthService } from './security/auth.service';
import { RegisterViewModel } from './models/RegisterViewModel';
@Component({
selector: 'register',
templateUrl: '/partial/registerComponent'
})
export class RegisterComponent {
registerViewModel: RegisterViewModel;
constructor(public router: Router, private titleService: Title,
public http: Http, private authService: AuthService) { }
ngOnInit() {
this.registerViewModel = new RegisterViewModel();
}
setTitle(newTitle: string) {
this.titleService.setTitle(newTitle);
}
register(event: Event): void {
event.preventDefault();
let body = { 'email': this.registerViewModel.email,
'password': this.registerViewModel.password,
'verifyPassword': this.registerViewModel.verifyPassword };
this.http.post('/Account/Register',
JSON.stringify(body), { headers: this.authService.jsonHeaders() })
.subscribe(response => {
if (response.status == 200) {
this.router.navigate(['/login']);
} else {
alert(response.json().error);
console.log(response.json().error);
}
},
error => {
// TODO: parse error messages, generate toast popups
// {"Email":["The Email field is required.",
// "The Email field is not a valid e-mail address."],
// "Password":["The Password field is required.",
// "The Password must be at least 6 characters long."]}
alert(error.text());
console.log(error.text());
});
}
}
接下来,在它旁边添加另一个新的 TypeScript 文件 login.component.ts,它将处理我们的登录,一旦我们注册了新用户。将 login.component.ts 编辑为:
import { Component } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { Http } from '@angular/http';
import { AuthService } from './security/auth.service';
import { LoginViewModel } from './models/LoginViewModel';
@Component({
selector: 'login',
templateUrl: '/partial/loginComponent'
})
export class LoginComponent {
loginViewModel: LoginViewModel;
constructor(public router: Router, private titleService: Title,
public http: Http, private authService: AuthService) { }
ngOnInit() {
this.loginViewModel = new LoginViewModel();
}
public setTitle(newTitle: string) {
this.titleService.setTitle(newTitle);
}
// post the user's login details to server,
// if authenticated token is returned, then token is saved to session storage
login(event: Event): void {
event.preventDefault();
let body = 'username=' + this.loginViewModel.email + '&password=' +
this.loginViewModel.password + '&grant_type=password';
this.http.post('/connect/token', body, { headers: this.authService.contentHeaders() })
.subscribe(response => {
// success, save the token to session storage
this.authService.login(response.json());
this.router.navigate(['/about']);
},
error => {
// failed; TODO: add some nice toast / error handling
alert(error.text());
console.log(error.text());
}
);
}
}
接下来,我们将更新现有的app.routing.ts 文件以包含我们的新组件,并将 about 组件标记为仅供已验证访问。将 app.routing.ts
更新为:
import { Routes, RouterModule } from '@angular/router';
import { AuthGuard } from './security/auth-guard.service';
import { AboutComponent } from './about.component';
import { IndexComponent } from './index.component';
import { ContactComponent } from './contact.component';
import { LoginComponent } from './login.component';
import { RegisterComponent } from './register.component';
const appRoutes: Routes = [
{ path: '', redirectTo: 'home', pathMatch: 'full' },
{ path: 'home', component: IndexComponent, data: { title: 'Home' } },
{ path: 'login', component: LoginComponent, data: { title: 'Login' } },
{ path: 'register', component: RegisterComponent, data: { title: 'Register' } },
{ path: 'about', component: AboutComponent, data: { title: 'About' },
canActivate: [AuthGuard] },
{ path: 'contact', component: ContactComponent, data: { title: 'Contact' }}
];
export const routing = RouterModule.forRoot(appRoutes);
export const routedComponents =
[AboutComponent, IndexComponent, ContactComponent, LoginComponent, RegisterComponent];
app.module.ts 文件也需要更新,以包含对我们的 AuthService
和 AuthGuard
服务的引用;将 app.module.ts 更新为:
import { NgModule, enableProdMode } from '@angular/core';
import { BrowserModule, Title } from '@angular/platform-browser';
import { routing, routedComponents } from './app.routing';
import { APP_BASE_HREF, Location } from '@angular/common';
import { AppComponent } from './app.component';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { HttpModule } from '@angular/http';
import { SampleDataService } from './services/sampleData.service';
import { AuthService } from './security/auth.service';
import { AuthGuard } from './security/auth-guard.service';
import './rxjs-operators';
// enableProdMode();
@NgModule({
imports: [BrowserModule, FormsModule, HttpModule, routing],
declarations: [AppComponent, routedComponents],
providers: [SampleDataService,
AuthService,
AuthGuard, Title, { provide: APP_BASE_HREF, useValue: '/' }],
bootstrap: [AppComponent]
})
export class AppModule { }
由于我们将 About 组件仅设为已登录用户可访问,我们将修改调用以使用包含 JWT 令牌的标头。由于这些标头会重用,我们已经在前面创建的 auth.service.ts 文件中放置了它们。在这种情况下:
import { Component, OnInit } from '@angular/core';
import { SampleDataService } from './services/sampleData.service';
import { TestData } from './models/testData';
@Component({
selector: 'my-about',
templateUrl: '/partial/aboutComponent'
})
export class AboutComponent implements OnInit {
testData: TestData;
errorMessage: string;
constructor(private sampleDataService: SampleDataService) { }
ngOnInit() {
this.getTestData();
}
getTestData() {
this.sampleDataService.getSampleData()
.subscribe((data: TestData) => this.testData = data,
error => this.errorMessage = <any>error);
}
addTestData(event: Event):void {
event.preventDefault();
if (!this.testData) { return; }
this.sampleDataService.addSampleData(this.testData)
.subscribe((data: TestData) => this.testData = data,
error => this.errorMessage = <any>error);
}
}
我们的 app.component.ts 文件将支持注销调用,并为 isLoggedIn
服务提供一个包装器;这将用于隐藏和显示注册和登录(当未登录时)或注销(当已登录时),以及隐藏未登录用户(例如 about 组件,在前面的 app.component 视图中)不可用的各种菜单选项。
将 app.component.ts 文件编辑为:
import { Component } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { Http } from '@angular/http';
import { AuthService } from './security/auth.service';
@Component({
selector: 'my-app',
templateUrl: '/partial/appComponent'
})
export class AppComponent {
angularClientSideData = 'Angular';
public constructor(private router: Router,
private titleService: Title, private http: Http, private authService: AuthService) { }
// wrapper to the Angular title service.
public setTitle(newTitle: string) {
this.titleService.setTitle(newTitle);
}
// provide local page the user's logged in status (do we have a token or not)
public isLoggedIn(): boolean {
return this.authService.loggedIn();
}
// tell the server that the user wants to logout; clears token from server,
// then calls auth.service to clear token locally in browser
public logout() {
this.http.get('/connect/logout', { headers: this.authService.authJsonHeaders() })
.subscribe(response => {
// clear token in browser
this.authService.logout();
// return to 'home' page
this.router.navigate(['']);
},
error => {
// failed; TODO: add some nice toast / error handling
alert(error.text());
console.log(error.text());
}
);
}
}
由于我们现在有四种不同的通用标头(用于表单提交与 JSON 调用,以及已登录与未登录),我们将重构 SampleData.service.ts 文件以使用其中一个新标头,并进行清理和添加一些注释。将 SampleData.service.ts 更改为:
import { Injectable } from '@angular/core';
import { Http, Response, Headers } from '@angular/http';
import { Observable } from 'rxjs/Observable';
import { TestData } from '../models/testData';
import { AuthService } from '../security/auth.service';
@Injectable()
export class SampleDataService {
private url: string = 'api/sampleData';
constructor(private http: Http, private authService: AuthService) { }
getSampleData(): Observable<TestData> {
return this.http.get(this.url, { headers: this.authService.authJsonHeaders() })
.map((resp: Response) => resp.json())
.catch(this.handleError);
}
addSampleData(testData: TestData): Observable<TestData> {
return this.http
.post(this.url, JSON.stringify(testData),
{ headers: this.authService.authJsonHeaders() })
.map((resp: Response) => resp.json())
.catch(this.handleError);
}
// from https://angular.io/docs/ts/latest/guide/server-communication.html
private handleError(error: Response | any) {
// In a real world app, we might use a remote logging infrastructure
let errMsg: string;
if (error instanceof Response) {
const body = error.json() || '';
const err = body.error || JSON.stringify(body);
errMsg = `${error.status} - ${error.statusText || ''} ${err}`;
} else {
errMsg = error.message ? error.message : error.toString();
}
console.error(errMsg);
return Observable.throw(errMsg);
}
}
最后,编辑 /wwwroot/css/site.css 以添加此新样式以支持隐藏属性:
/* hidden .. see: http://www.talkingdotnet.com/dont-use-hidden-attribute-angularjs-2/ */
[hidden] {
display: none !important;
}
为了防止我们的用户名和密码框触发表单错误,我们将修改我们的标签助手,将其包装在现有的验证 DIV
周围再加一层:
将 /helpers/TagDiTagHelper.cs 文件编辑为包含这两行:
// set up validation conditional DIV's here; only show error
// if modifications to form have been made
TagBuilder outerValidationBlock = new TagBuilder("div");
outerValidationBlock.MergeAttribute
("*ngIf", string.Format("({0}.dirty || {0}.touched)", propertyName));
在这两行代码的上面,紧邻现有的验证 DIV
块:
// .. and then, only if an error in data entry
TagBuilder validationBlock = new TagBuilder("div");
validationBlock.MergeAttribute("*ngIf", string.Format("{0}.errors", propertyName));
validationBlock.MergeAttribute("class", "alert alert-danger");
并更改最后一行代码,它曾经是这样的:
output.Content.AppendHtml(validationBlock);
}
}
}
将其更改为将现有的验证块包含在我们新的外部验证 DIV
块中,使其如下所示:
// add the validation prepared earlier, to the end, last of all
outerValidationBlock.InnerHtml.AppendHtml(validationBlock);
output.Content.AppendHtml(outerValidationBlock);
}
}
}
全部完成,准备测试。由于我没有添加一系列数据库更新方法,再次为了保持代码的简单和专注,请使用 SQL 管理器手动删除现有数据库,以便 startup.cs 中的数据库初始化代码能够正常工作。
构建代码,按 Ctrl-F5。浏览器将启动,并且由于这是该代码(本次)第一次执行,只要代码处于调试模式,它就会重新创建数据库,但这次将包含新的身份验证表以及我们的 testdata 表,并且仍然包含之前的一行种子数据。
如果一切正常,您也应该看到主页。
尝试手动访问关于页面 /about,您应该会被带到登录页面。
由于我们还没有注册(您可以选择查看预先注册的访问权限),请单击注册链接,填写用户名,输入两次密码,然后单击 Submit。
注册完成后,您将被带到登录页面。
现在在登录页面,您需要输入刚刚创建的用户名和密码,然后您将被带到关于页面。这仍然是(或多或少)与第 3 部分相同的关于页面,只不过现在它已通过 OpenIdDict
和令牌进行保护。
查看菜单,您将看到已登录视图已就位,我们不再有 Register 或 Login 菜单链接,而是有一个 Logout 菜单链接。单击 Logout,关于页面再次被锁定,Logout 链接从菜单中隐藏,Register 或 Login 菜单链接被显示出来。
如果您安装了 Chrome 浏览器,可以尝试添加 Augury 插件。该插件专为 Angular 2 设计,可以洞察您的应用程序内部正在发生的事情。
在后台,您可以使用 F12 查看网络流量,然后选择网络选项卡。这里,Firefox 显示了成功登录后返回的令牌。
Chrome 和 Firefox(取决于您的浏览器设置)让您可以轻松查看 SessionStorage
。
当您登录时,它将包含一个令牌,当您注销时,该令牌将从浏览器中移除(并在服务器上失效)。例如,这是客户端从服务器获取数据时在请求标头中发送的令牌。在 Internet Explorer 中,我们可以看到令牌。
下一步?
在下一部分,我们将更新我们的 Web API 数据服务,添加客户端 datagrid
,然后将我们的 Web API 服务转换为 async
。
接下来,我们将演示如何使用 NSwag 直接从您的 C# 数据模型和 Web API 类/方法生成 Angular 2 的 TypeScript 数据模型和数据服务。
如果您需要发布或记录您的 Web API 方法,也可以使用 NSwag,就像使用 Swagger 一样。
这将使更改比以前更简单;每次添加新属性时,都可以轻松添加支持 - 所有这些都简单且类型安全,然后让您只需处理 Angular 2 组件和模板即可。
关注点
同样,由于重点是 ASP.NET Core 和 Angular 2 之间的集成,本系列文章更侧重于这种交互,而不是详细涵盖每一个方面。
在标签助手方面仍有工作要做(重构和涵盖其他数据类型),以及清理我们的服务,包括添加 async。您可以从本部分(第 4 部分)关于身份验证的方面考虑进行一些增强:
- 用户角色 - 您已拥有现成但可扩展的身份验证和角色对象。如果您愿意,可以自定义它们,但很可能会发现它们在开箱即用的情况下就能满足您的绝大多数需求。
- 用户管理 - 您拥有标签助手以节省时间,创建一些服务、组件和视图,让您可以编辑您的用户详细信息,添加新用户,或让您的用户更新他们的详细信息。
- 错误消息 - 使用众多“Toast”实用程序之一来添加更干净的错误和成功消息。
- 处理超时和允许刷新或会话延长 - 您可以设置一个计时器或警报,以便当用户接近会话结束时,他们会收到时间即将结束的警告。您可能希望为他们提供刷新或延长选项,或者简单地说 - 时间到了。无论哪种方式,您都可以通过登录时返回的数据中获取时间信息。
历史
- 第 1 部分 - 如何集成 ASP.NET Core 和 Angular 2
- 第 2 部分 - 如何使用标签助手显示数据
- 第 3 部分 - 如何将标签助手用于数据输入,添加使用 EF Core 的 SQL 后端。
- 第 4 部分 - 如何使用 JWT 和 OpenIdDict 添加令牌身份验证(本文)