六、使用身份服务器和 OAuth 2 的股票检查器

在现代发展中,建立一个可靠和安全的接口来验证您的用户是绝对必要的。OAuth 2 已经成为这里事实上的标准;然而,由于其历史,OAuth 2 的确切含义取决于你问谁(也就是说,如果你问谷歌,他们可能会告诉你一件与 Twitter 略有不同的事情)。

事实上,如果你想让某人简单地使用安全界面登录,而你对登录过程的细节不感兴趣,你可能会做得比使用这些公司之一来提供你的身份服务差得多。例如,用户可以用他们的推特凭证登录你的网站。

在本章中,我们将开发一个股票检查应用。我们的应用将非常基本:我们将允许人们输入股票代码并获得股票数字,我们还将允许其他人更新股票数字。我们不能依赖互联网接入,因此我们将无法使用在线身份服务。

为了实现这一点,我们将使用一个名为 identity server(https://identityserver.io/)的开源框架。

It's worth bearing in mind that what we are about to build, using IdentityServer, may be overkill for your specific usage scenario. If all you want is to authenticate a user, then you may find that Twitter, Google, or Microsoft's pre-built implementations of OAuth 2 are better suited to your needs. What we will do here is build a custom identity server, albeit using a framework, but it is still more work than using a pre-built offering.

在本章中,我们将涵盖以下主题:

  • 使用身份服务器保护应用编程接口
  • 实现简单的基于角色的权限模型
  • 通用 Windows 平台 ( UWP )应用
  • 创建 ASP.NET Core 3.0 应用编程接口
  • 实体框架核心
  • 为开发目的创建证书

技术要求

在本章中,我们将使用英孚核心和 SQL Server。在本章中,我将假设您运行的是本地安装的 SQL Server 版本。如果您选择连接到不同的 SQL Server 实例,应该没有区别(除了连接字符串)。SQL Server 的下载页面可以在这里找到:https://www . Microsoft . com/en-GB/SQL-Server/SQL-Server-downloads

SQL Server 开发人员版可免费用于开发和测试软件(就像您将在这里做的那样)。也可以使用 SQL Server Express,甚至是商业版的 SQL Server;然而,就目前而言,其中一个免费版本对于这个项目来说已经足够了。

您还需要一种测试应用编程接口的方法。邮差就是这样一个可以用于此的工具,可以在这里找到:https://www.getpostman.com/

你可以在这里找到邮递员的文档(包括一些入门教程):https://learning.getpostman.com/docs。

Visual Studio 安装程序–新工作负载

通过选择“修改”选项,可以通过 Visual Studio 安装程序安装其他工作负载。

为了创建 UWP 应用,您需要安装 UWP 工作负载:

您可能希望为 Visual Studio 下载数据工具:

或者也可以下载 SQL Server Management Studio,可以在这里找到:https://docs . Microsoft . com/en-us/SQL/ssms/download-SQL-Server-Management-Studio-ssms

身份和许可

在我们开始实现我们的解决方案之前,理解这两个概念(以及它们的同义词)是很重要的。在我们的应用中(除了实际检查库存),我们有两个不同的要求:

  • 只有通过认证的人才可以使用。也就是说,作为用户,您必须已成功登录系统。
  • 认证使用该软件的人中,只有其中的一部分可以被授权更新库存数字。

为了更好地说明这一点,让我们想象一个虚构的公司和与该公司有关联的四个人;假设我们公司销售建筑用品:我们就叫 PCM 建筑用品有限公司

格雷厄姆是公司的现场经理;他负责现场发生的一切,包括检查库存水平是否正确,以及当库存水平下降到一定水平以下时向供应商订购。

露西从事销售工作:她接受顾客的订单,并负责装运货物。

莫里斯是看守人,他负责维护大楼,清理场地,每天晚上都锁门。

山姆是一名建筑商,他从 PCM 建筑用品公司购买产品..

在我们的例子中,格雷厄姆需要访问系统并获得检查和更新库存的许可,因为他是站点经理。

露西需要进入系统,但只需要查看库存的权限,不需要更新数字。

莫里斯确实在公司工作,需要进入系统;他只需要更新库存的许可,因为他没有理由检查库存,但可能会在工作中使用一些。

最后,萨姆既不需要进入也不需要许可,因为她不为公司工作。

This company is a fictitious one. We'll use it throughout this chapter for the purpose of testing data to illustrate our product working in these scenarios.

Having never worked at a building supply company, these examples may not reflect reality. If you're thinking that what I've said makes no sense in a real building supply company, then I would ask that you suspend belief as this is purely for the purpose of illustration. Having said that, the principles here are applicable, regardless of the industry you apply them to: this system could easily be applied to a newsagent, a greengrocer, or a clothes shop.

既然我们已经讨论了本章中关键主题背后的概念,让我们继续讨论项目本身。

项目概述

由于我们的要求之一是,该解决方案应尽可能在没有连接的情况下工作,如果没有连接,只需连接到本地网络,我们的项目将由本地托管的 ASP.NET Core 应用编程接口和 UWP 应用组成。每个需要访问该应用的人都将获得一个平板电脑,他们可以在其中检查或更新库存。我们系统的架构看起来像这样(不完全是原始架构):

UWP 客户端应用将提供给所有工作人员,这意味着,除了验证用户之外,该应用还需要防止某些用户访问某些功能。

股票检查应用接口

首先,我们将创建我们的股票检查器应用,它的所有功能对每个人都是启用的,我们将使用 IdentityServer 将应用锁定到只有经过身份验证的用户,最后,我们将只为用户启用正确的功能。

设置

第一阶段将是创建我们的应用编程接口。英寸 NET Core,一个ApiController的概念被简单的Controller代替;也就是说,服务于数据的控制器方法和服务于除返回类型之外的网页的控制器方法之间没有区别。

让我们创建新项目:

我们将创建一个空的应用,并手动添加应用编程接口(目标.NET Core 3.0):

这应该给你一个基本的网络应用;现在,我们可以通过添加一个新的控制器在这些骨头上放一些肉。

添加控制器和路由

在我们的新应用中,我们需要创建自己的控制器。让我们看看如何:

  1. 首先将这些控制器放在它们自己的名为Controllers的文件夹中:

  1. 然后,右键单击文件夹,并选择添加|控制器...:

  1. 您可以让 Visual Studio 为您创建您的控制器,但是,让我们再次滚动我们自己的并选择空:

The new controller inherits from ControllerBase. This is because the Controller class (which itself inherits from ControllerBase) adds some functionality for binding views that relates only to an MVC controller. You could change this to inherit from Controller and it would work fine (although you would be including some functionality that you won't need). You'll notice that the new controller is decorated as an ApiController. Again, this is optional, but it adds some basic validation for you.

因此,您最初的controller方法应该是这样的:

[Route("api/[controller]")]
[ApiController]
public class StockController : ControllerBase
{
}
  1. 为了让它工作,让我们添加一些非常基本的代码,让它返回一些东西:
[HttpGet]
public string Get()
{
   return "test";
}
  1. 现在,我们需要插入一些中间件来找到控制器。在startup.cs中,更改ConfigureServices方法,如下所示:
public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
}

这是一个扩展方法,告诉框架您将为 web API 使用与控制器和控制器映射相关的服务,因此它将添加授权和 CORS。

In previous versions of .NET Core, you would add .UseMvc or .UserMvcCore here; however, in .NET Core 3, the framework expects you to add only the parts that you need. This means that your code will never include middleware that isn't necessary. Clearly, the downside is that there's slightly more work to do initially to set up the API.

  1. Configure方法中,告诉应用使用我们之前添加的服务:
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    if (env.IsDevelopment())
    {
         app.UseDeveloperExceptionPage();
    }

    app.UseRouting();
    app.UseAuthorization();
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}
  1. 现在,您应该能够运行应用并导航到控制器;确切的地址取决于您的端口,但可能如下所示:
https://localhost:44371/api/stock

现在我们有了一个控制器,我们可以创建我们的股票功能。我们有两个要求:检查库存水平和更新库存水平。我们还需要保存这些信息,所以我们将使用 EF Core。

阅读库存水平

让我们看看如何使用实体框架核心将这一点保留到 SQL Server 数据库中:

  1. 因为我们将所有这些都保存到一个数据库中,所以我们将在我们的应用编程接口中安装实体框架核心:
Install-Package Microsoft.EntityFrameworkCore.SqlServer
Install-Package Microsoft.EntityFrameworkCore.Tools

这将安装所需的实体框架库和工具。

  1. 下一步是创建我们的模型;也就是说,在 C#类中创建数据库的映射。您的模型可能看起来像这样:
public class Product
{
    public int Id { get; set; }
    public string Description { get; set; }
    public int StockCount { get; set; }
}

它应该位于应用可见的地方。我已经把我的添加到一个名为Models的子文件夹中。

For larger applications, it can make sense to take all of the data access logic and move it to its own library, but since this is a very small project, the extra overhead is probably not warranted at this time. If you maintain an abstraction between the data access and the business logic, moving the code later should be a trivial task. By default, Entity Framework Core uses any fields suffixed with Id to create a primary key. In our case, Id will be treated as a primary key. 

Database performance, indexes, and keys are beyond the scope of this chapter and book; however, if you decide to extend this project, it is very likely that, for any volume of data, you would need to consider such things.

  1. 下一步是创建数据上下文。这实际上是一个映射类,用来告诉 EF Core 使用哪些类并映射到您的数据库。目前我们的需要是这样的:
public class StockContext : DbContext
{
    public StockContext(DbContextOptions<StockContext> options) 
        : base(options) { }
    public DbSet<Product> Products { get; set; }
}

这个类有两个重要的部分:声明DbSet,它告诉 Entity Framework Core 我们想要持久化什么,以及它继承了什么,也就是DbContext(它的构造函数)。

  1. 最后,我们需要告诉 ASP.NET Core,我们要用实体框架核心,去哪里找DbContext,去哪里找数据库。这些都位于ConfigureServices内部:
public void ConfigureServices(IServiceCollection services)
{
    services.AddMvcCore();
    var connection = @"Server=(localdb)\MSSQLLocalDb;Database=StockCheckerDB;Trusted_Connection=True;ConnectRetryCount=0";
    services.AddDbContext<StockContext>(options => options.UseSqlServer(connection));
}

You may wish to move the connection string into the config file and use SQL Server security rather than a trusted connection.

  1. 现在我们已经配置了数据,我们将创建一个迁移来更新数据库以反映我们的模型。在包管理器控制台中,键入以下内容:
Add-Migration InitialMigration

这应该会在您的项目中创建新文件夹,称为Migrations,以及两个新文件。如果您快速查看迁移文件,您会看到它由两个功能组成:Up,它告诉 EF 当您向前迁移时该做什么,以及Down,它应该恢复这些更改。

Although this is generated code, it is not continually generated, which means that you can change it if you wish. Be aware that if you change Up, but not Down, you may find that you can't revert a migration, or worse, that reverting the migration leaves you in a new state: neither new nor old.

  1. 下一步是更新数据库(只运行迁移):
Update-Database

现在我们的数据库已经存在,可以访问,并且是最新的,我们将需要我们的控制器函数来访问这些数据。

您可以在应用的任何地方简单地访问DataContext;然而,这会给单元测试带来问题;也就是说,如果你的控制器函数直接访问数据库,那么很难测试一个单元的功能。此外,如果您决定在以后用另一种数据访问方法替换实体框架,这将使它变得更加困难。

为了解决这些问题,我们将把依赖注入到控制器中。让我们看看如何:

  1. 为了将依赖注入到我们的控制器中,第一步是将我们的DbContext类抽象到一个接口中:
public interface IDbContext
{
    DbSet<Product> Products { get; set; }
}

You might be wondering why we would create an interface and not pass the class in directly. In fact, there is nothing preventing this; however, what this would mean is that we would always need to pass in a class of the DbContext type. Creating an interface means that we can replace our DbContext class with a dummy class, or even a completely different class that implements the same interface.

This may seem like abstraction for the sake of it, but consider how you would write a unit test for any method that referenced this DbContext.

For our project, we will simply pass the DbContext around; however, the best practice is to completely abstract the data access, so rather than passing in DbContext, you may pass in an IDataAccess class, which in turn accepts the IDbContext. This means that, should you decide to replace EF Core with another ORM, you would simply change the implementation of this class.

  1. 现在我们有了一个接口,我们可以将它注入控制器:
public StockController(IDbContext dbContext)

If you're using Visual Studio, pressing Ctrl-. on DbContext will give you the opportunity to create and populate a field in the class, saving you from adding the class-level variable:

  1. 最后,当调用Get方法时,我们将从数据库返回数据:
[HttpGet("{id}")]
public ActionResult<int> Get(int id)
{
    Product product = dbContext.Products.FirstOrDefault(a => a.Id == id);
    if (product == null) return NotFound();

    return Ok(product.StockCount);
}

你的StockController现在应该是这样的:

[Route("api/[controller]")]
[ApiController]
public class StockController : ControllerBase
{
    private readonly IDbContext dbContext;

    public StockController(IDbContext dbContext)
    {
        this.dbContext = dbContext;
    }

    [HttpGet("{id}")]
    public int Get(int id)
    {
        Product product = dbContext.Products.FirstOrDefault(a => a.Id == id);
        if (product == null) return NotFound();

        return Ok(product.StockCount);
    }
}
  1. 我们现在有一个依赖注入到我们的控制器;然而,我们需要一些东西来为我们注射。这就是 IoC 容器的作用。在 ASP.NET Core 之前,你可能已经使用了类似 Unity 的东西。如果你愿意,你仍然可以,但是 ASP.NET Core 3 有一个内置的 IoC 容器:
public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();

    var connection = @"Server=(localdb)\MSSQLLocalDb;Database=StockCheckerDB;Trusted_Connection=True;ConnectRetryCount=0";
    services.AddDbContext<StockContext>
 (options => options.UseSqlServer(connection));
    services.AddTransient<IDbContext, StockContext>();
}

There are many IoC containers available for free. If you choose to use a third-party one, then I would advise that you have a reason to do so; while some of the options out there do offer features that the built-in version does not, you should consider whether that functionality is something that you actually need. Furthermore, outside of experimentation, I would advise against writing your own IoC container as this leaves you with the responsibility of maintaining it in the future.

所以,我们现在应该可以运行这个程序,得到一个股票数字;让我们试试。执行 API 它应该会启动一个会显示 404 错误的浏览器(没关系——只是基址什么都没有)。导航到以下地址:

https://localhost:44371/api/stock/1

Your port may be different—if it is, then simply substitute your port.

现在,您应该能够导航到以下地址:

https://localhost:44371/api/stock/1

浏览器应正确返回股票数字(即0)。

更新库存水平

现在我们可以读取库存水平,让我们添加更改它们的功能。现在检索完成了,更新相对琐碎;你的Update方法应该是这样的:

[HttpPut("{id}")]
public IActionResult Update(int id, [FromBody]int stockCount)
{
    Product product = dbContext.Products.FirstOrDefault(a => a.Id == id);
    if (product == null) return NotFound();
    product.StockCount = stockCount;
    dbContext.SaveChanges();
    return NoContent();
}

如您所见,代码与检索非常相似;我们只需更新dbContext上的库存数量并调用SaveChanges()

为了测试这个,我们需要使用 Postman(如果你还没有安装这个,那么参考技术要求部分):

您应该会发现这将项目1的库存水平更新为数量6。您可以通过查看数据库来证明这一点,或者您可以简单地导航到端点来检查库存水平(使用邮递员或浏览器,就像您以前做的那样)。

You may have noticed that we have not built in any functionality to add new products. This is intentional; if you wish to do so as an extension, I would urge you to consider whether it fits in the Stock controller or whether that should be handled separately.

许可

目前为止,一切顺利。我们现在有了股票检查应用的基本功能;也就是说,它检查库存水平,并允许我们更新库存水平。然而,这有一个问题。在我们的示例中,任何用户都可以轻松更新库存水平。这可以(也应该)锁定在客户端上;然而,我们也应该在 API 上实现这种安全性;毕竟,访问 Postman,甚至网络浏览器,并不是排他性的,我们最不希望的是有人未经允许就更新我们的股票水平。

让我们插入标识服务器 4 ,并确保访问应用编程接口的人至少被授权这样做。

客户应用

在我们可以引入 IdentityServer 来验证正确的人和应用可以访问 API 之前,我们需要有一个我们可以说可以合法访问 API 的应用;否则,我们能做的最好的事情就是完全阻止对 API 的访问。我们的客户端应用将使用通用视窗平台 ( UWP )构建。

UWP is Microsoft's preferred method for building Desktop applications. WPF and WinForms are still supported (and if you've read the previous chapters, you'll see that they're getting a new lease of life). However, for new applications, it is recommended that you use UWP. In fact, XAML Islands are a way to bridge the gap between the old and the new.

我们的应用将非常简单:我们只需要一个单一的屏幕与股票水平的查找和更新股票水平的选项。

I've never claimed to be a UX designer, so if you feel you could design the screen better, that's probably because you could (and there is nothing in the functionality that will be altered if the layout of the screen is changed)!

让我们在解决方案中创建一个新项目:

我们的客户端应用将是一个 C# UWP 应用:

In this project, we will leverage the binding capabilities of UWP; however, it would be wrong to say that this represents an MVVM architecture. Data binding, while an important part of an MVVM architecture, is not synonymous with it. For this project, I am purposely not introducing any MVVM frameworks in order to demonstrate how the project is built; however, other than the learning opportunity that this affords, it is very much reinventing the wheel. There are several excellent MVVM frameworks out there: MVVM Cross or MVVM Light, for example. All of them will provide built-in helpers for commands, messaging, and dependency injection.

UWP 允许您简单地编写事件处理程序;因此,理论上,我们可以处理按钮的点击事件,询问屏幕,然后调用 API。事实上,我们的 UI 层非常小,这可能代表了最好的解决方案;但是,我们将使用 UWP 开箱即用的内置数据绑定。这种方法也使解决方案更具可扩展性。

让我们首先创建一个ViewModels文件夹,并为我们的主视图添加一个视图模型:

public class MainPageViewModel : INotifyPropertyChanged

我们将很快解释为什么我们要实现这个特殊的接口。视图模型的目的是在代码中提供视图的表示:也就是说,所有的功能,但没有一个视觉效果。

You can call the ViewModel anything you choose; however, should you elect to use a particular MVVM framework, some of them use a convention that the ViewModel should have the same stem as the View; for example, MainPageView/MainPageViewModel.

我们将在视图模型中声明的第一件事是我们将显示和更新的字段;在我们的例子中,我们实际上只显示了两个:

private int _productId;
private int _quantity;
private int _originalQuantity;

public int ProductId
{
    get => _productId;
    set
    {
        if (UpdateField(ref _productId, value))
        {
            RefreshQuantity();
        }
    }
}

public int Quantity
{
    get => _quantity;
    set
    {
        if (UpdateField(ref _quantity, value))
        {
            UpdateQuantity.RaiseCanExecuteChanged();
        }
    }
}

显然,我们引用了一些这里不存在的方法;UpdateField只是一个帮助器方法,它可以省去我们重写字段已经改变的检查,以及调用OnPropertyChanged的地方(一秒钟后会有更多信息):

private bool UpdateField<T>(ref T field, T value,
 [CallerMemberName] string propertyName = null)
{
    if (EqualityComparer<T>.Default.Equals(field, value)) 
    {
        return false;
    }

    field = value;

    OnPropertyChanged(propertyName);
    return true;
}

OnPropertyChanged

XAML 的工作方式是在必要时重新渲染屏幕。在 WPF 和 UWP 的例子中,这意味着我们需要告诉它一些事情已经改变了,我们通过在INotifyPropertyChanged接口上实现一个名为OnPropertyChanged的方法来做到这一点:

public void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
    this.PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}

当调用这个函数时,它会重新呈现屏幕上绑定到传入的任何属性的方面。

CallerMemberName was introduced back in .NET 4.5 and it allows you to reference the name of the caller without explicitly defining it at design time. That is, if we change the name of one of the properties, CallerMemberName will simply pick up the new name.

命令和应用编程接口调用

现在我们有了自己的属性,我们需要引入两个功能:更新库存数量的能力和检索库存数量的能力。我们已经提到了后者,所以让我们先补充一下:

private async Task RefreshQuantity()
{
    Quantity = await _httpClientHelper.GetQuantityAsync(ProductId);
    _originalQuantity = Quantity;
    UpdateQuantity.RaiseCanExecuteChanged();
}

同样,我们显然引用了一些尚不存在的代码,但是让我们检查一下我们看到的内容:我们只是调用我们的 API,将数量值赋给我们的属性,然后设置_originalQuantity字段。_originalQuantity场和RaiseCanExecuteChanged联系紧密,我们很快就会看到原因。不过在此之前,我们先来看看_httpClientHelper是从哪里来的。我们将在这里添加一个构造函数和字段定义:

private readonly IHttpStockClientHelper _httpClientHelper;
public RelayCommand UpdateQuantity { get; set; }

public MainPageViewModel(IHttpStockClientHelper httpClientHelper)
{
    _httpClientHelper = httpClientHelper;
    UpdateQuantity = new RelayCommand(async () =>
    {
        await _httpClientHelper.UpdateQuantityAsync( 
            ProductId, Quantity);
        await RefreshQuantity(); 
    }, () => Quantity != _originalQuantity); 
}

这里发生了很多事情。再一次,我会让你暂时保留关于这些接口和变量类型是什么的问题,看看我们能看到什么:我们正在注入我们之前看到的助手类,我们正在实例化一个RelayCommand,它显然只是采取一个动作(做一些事情)和一个函数(评估一些事情)。

这代表了视图模型的所有代码,所以让我们来看看助手类。

助手类

我们在这里使用了两个助手类;第一次是RelayCommand。任何绑定到 XAML 前端的命令都必须实现ICommand。执行ICommand是一件微不足道的工作;你只需告诉它你想在它执行时做什么,以及允许它执行的条件。然而,这确实意味着每个命令都有一个单独的类,这使得从视图模型传递功能变得更加困难。因此,解决方案是一般地实现一个简单地接受动作和评估函数并为您实现ICommand的助手类。看起来是这样的:

public class RelayCommand : ICommand
{
    private readonly Action _execute;
    private readonly Func<bool> _canExecute;
    public event EventHandler CanExecuteChanged;

    public RelayCommand(Action execute) : this(execute, null)
    {
    }

    public RelayCommand(Action execute, Func<bool> canExecute)
    {
        _execute = execute ?? throw new ArgumentNullException("execute");
        _canExecute = canExecute;
    }

    public bool CanExecute(object parameter)
    {
        return _canExecute == null ? true : _canExecute();
    }

    public void Execute(object parameter)
    {
        _execute();
    }

    public void RaiseCanExecuteChanged()
    {
        CanExecuteChanged?.Invoke(this, EventArgs.Empty);
    }
}

There are many versions of this that are available as open source, not least from Microsoft themselves. All of the implementations are basically the same; although if you decide to use it yourself, you might find it needs a little customization; for example, some logging will go a long way! As far as I'm aware, all of the MVVM frameworks provide a version of this that is likely to be much richer in functionality than anything you'll write yourself.

我们的第二个助手类是HttpClientHelper。让我们看看代码。然后,我们可以讨论它的功能,更重要的是,它为什么需要放在一个单独的类中:

public class HttpClientHelper : IHttpStockClientHelper
{
    static HttpClient _httpClient;

    public HttpClientHelper(Uri baseAddress)
    {
        _httpClient = new HttpClient();
        _httpClient.BaseAddress = baseAddress;
    }

    public async Task<int> GetQuantityAsync(int productId)
    { 
        string path = $"api/stock/{productId}";
        string quantityString = await _httpClient.GetStringAsync(path);
        return int.Parse(quantityString);
    }

    public async Task UpdateQuantityAsync(int productId, int newQuantity)
    {
        string path = $"api/stock/{productId}";
        var httpContent = new StringContent(newQuantity.ToString());
        httpContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");

        await _httpClient.PutAsync(path, httpContent);
    }
}

如您所见,我们有两个公共方法和一个构造函数。因为我们调用同一个服务,所以我们可以在构造函数中配置所有这些。如您所见,我们只是使用GetQuantityAsyncHttpGetUpdateQuantityAsyncHttpPut来调用服务。

值得注意的是,这两种方法都在进行某种类型转换:帮助器方法公开您期望的功能是有意义的;例如将产品数量更新为 3 。在这里,你应该只需要两个参数:产品和数量。如果你传入或传出任何其他东西,那么你就制造了噪音,如果出现错误,你(或其他人)可能不得不进行筛选。

所以,降低噪音是有这个帮手的第一个原因。第二,如果我们需要对调用代码进行单元测试,我们可以很容易地模拟出对服务的调用。

我们现在有了一个可以工作的桌面应用,所以让我们继续保护功能。我们可以从身份服务器开始。

标识服务器 4

正如我们前面提到的,IdentityServer 不是一个预构建的服务,而是一个框架。这样的服务确实存在——谷歌、推特、脸书、微软等等都提供了预先构建的服务,你可以简单地调用这些服务并找回身份。IdentityServer 更像是一个自己的roll解决方案。

It's worth considering why you might choose to roll your own in this manner. In our example here, one of the requirements is offline access, so that does weight the argument – you can't authenticate using Facebook if you're not online. It's also worth considering whether you would want to outsource the authentication of your users to a third party. I'm not saying for a minute that these aren't reliable, secure services, but they are run by companies. If you build your entire application around Facebook authentication and they suddenly withdraw the service for some reason, where would that leave you?

让我们从创建我们的身份服务器开始,它可以是一个标准的 ASP.NET Core 网络应用:

同样,我们将创建一个空的应用,这样我们就可以确切地看到它是如何构建的。

All of the instructions in this section relate to the new (IdentityServer) project that you have just created, unless otherwise stated.

在包管理器控制台中,我们将安装IdentityServer4包:

Install-Package IdentityServer4 -ProjectName StockChecker.IdentityServer

If you have decided to call your project something different than mine, then you'll need to change the preceding project name.

Startup.cs中,我们需要添加IdentityServer:

public void ConfigureServices(IServiceCollection services)
{
    services
        .AddIdentityServer()
        .AddDeveloperSigningCredential();
}

我们在这里做两件事:我们将身份服务器添加到依赖注入 ( DI )系统,并且我们将添加一些临时凭证。

We'll revisit this later and add some (more) valid credentials, but this will get us up and running.

接下来,我们需要向 ASP.NET Core 中间件管道注册身份服务器:

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }
    app.UseIdentityServer();
}

为了运行 IdentityServer,我们需要做的最后一件事是告诉它什么将请求信息;这可以在ConfigureServices(在Startup.cs)中完成:

public void ConfigureServices(IServiceCollection services)
{
    services
        .AddIdentityServer()
        .AddDeveloperSigningCredential()
        .AddInMemoryClients(new List<Client>())
        .AddInMemoryIdentityResources(new List<IdentityResource>());
}

显然,这不会让我们在这个阶段实际验证任何东西。本质上,为了正确运行,IdentityServer 需要知道三件事:

  • 谁需要访问权限(用户)
  • 他们需要获得什么(资源)
  • 他们将如何获得访问权限(客户端)

IdentityServer provides helper methods, such as AddInMemoryClients, as a way to get started. At some stage in the future, additional clients or resources may need to be added, and this could be easily refactored so that the list of each is persisted into a data store.

标识服务器

目前,我们有一个运行的身份服务器;但是,我们可以在没有任何凭据的情况下运行客户端并使用我们的应用。在我们向 identity server 添加资源、客户端或用户之前,下一步是让它拒绝我们进入(因为我们还没有设置这些东西)。

保护应用编程接口

为了保护应用编程接口,我们只需要做两件事(这两件事都不需要身份服务器——其中一件已经为我们完成了!).首先,我们需要告诉 ASP.NET Core,我们想要使用授权。在我们的启动文件中,我们已经在调用AddControllers。因为 ASP.NET Core 现在是开源的,我们可以简单地看看这对我们有什么好处:

private static IMvcCoreBuilder AddControllersCore(IServiceCollection services)
{
    return services
        .AddMvcCore()
        .AddApiExplorer()
        .AddAuthorization()
        .AddCors()
        .AddDataAnnotations()
        .AddFormatterMappings();
}

A common practice in many Microsoft products (especially .NET Core products) is to use the Builder pattern to allow configuration of the middleware. The premise of this pattern is simply that the method performs an action and then returns a reference to the object that it was called from. This allows for a more human-readable code flow (as shown in the preceding code).

这里的相关线是AddAuthorization。接下来我们需要做的是告诉 ASP.NET Core 我们想要获得什么。在控制器中,添加以下装饰器:

[Authorize]
[Route("api/[controller]")]
[ApiController]
public class StockController : ControllerBase
{

现在,如果您尝试访问该 API,您将会得到一个错误。好吧。所以现在我们根本无法访问 API 让我们插入 IdentityServer 代码。这里的原则很简单:我们将从我们的身份服务器请求一个令牌,允许我们访问我们的应用编程接口。在我们的应用编程接口中,我们将安装一个身份服务器包:

Install-Package IdentityServer4.AccessTokenValidation -ProjectName StockChecker.Api

Strictly speaking, you can do this part without IdentityServer at all by adding a JWTBearer authentication. However, using IdentityServer does give you certain advantages here and, since we're already using IdentityServer, it doesn't really make sense to start rolling our own for part of the solution.

在我们的应用编程接口中,我们需要ConfigureServices中的以下代码:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();

    services.AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
        .AddIdentityServerAuthentication(options =>
    {
        // Base-address of our IdentityServer 
        // (if you haven't purposely changed it then this is likely correct)
        options.Authority = "https://localhost:5001";

        // Name of the API resource
        options.ApiName = "StockCheckerApi";
});

我们将在管道中添加身份验证:

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }
    app.UseAuthentication();

    app.UseRouting();
    app.UseAuthorization();
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}

客户端配置

我理解这一章的流程可能看起来有点随意,但事实上,这里面有一个思考过程。我们首先保护了 API,这意味着我们保护了我们的资源。我们要做的下一件事是更改客户端,这样它将获得令牌并正确调用 API(这是这一部分);显然,这还不行,因为我们还没有改变我们的身份服务器。在下一节中,我们将改变 IdentityServer,我们应该看到一切都突然跃入生活。之所以按照这个顺序来做,是因为我经常发现,如果你先看到某件事没有起作用,就更容易看到它是如何起作用的(否则,你就真的不知道自己做对了什么)。

在我们的 UWP 应用中,我们需要另一个 NuGet 包:

Install-Package IdentityModel -ProjectName StockChecker.UWP

UWP 应用(或一般的桌面应用)在两个重要方面不同于 web 应用:第一个是,在 web 应用中,您必须处理用户可以简单地在应用中的任何地方导航的事实。例如,用户可以简单地将登录屏幕上的地址栏更改为以下内容:

https://www.mysecuresite.com/products/stock/1

因此,在保护 web 应用时,不能依赖屏幕的预期流量;但是,在桌面应用中,您可以。

第二个考虑是用户的桌面上有桌面应用的代码。我们正在努力.NET,这意味着对代码进行逆向工程是一项非常琐碎的任务;然而,如果有足够的意愿,我知道没有哪种语言不能在某种程度上进行逆向工程。

这里的要点是,我们可以期望用户进入登录屏幕,并以最小的努力将他们保持在那里,但是我们不应该在客户端设备上存储任何可能允许用户访问服务器的内容。

登录屏幕

让我们创建一个新的登录屏幕;我已经调用了我的页面LoginView。以下代码在<Page>元素中:

<Grid HorizontalAlignment="Center" VerticalAlignment="Center">
    <Grid.RowDefinitions>
        <RowDefinition Height="Auto" />
        <RowDefinition Height="Auto" />
        <RowDefinition Height="Auto" />
    </Grid.RowDefinitions>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="Auto" MinWidth="200" />
        <ColumnDefinition Width="Auto" MinWidth="200" />
    </Grid.ColumnDefinitions>
    <TextBlock Text="Username" Margin="5"
               Grid.Row="0" Grid.Column="0" />
    <TextBlock Text="Password" Margin="5"
               Grid.Row="1" Grid.Column="0" />
    <TextBox Text="{Binding Username, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Margin="5"
             Grid.Row="0" Grid.Column="1" />
    <PasswordBox Password="{Binding Password, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Margin="5"
                 Grid.Row="1" Grid.Column="1" />
    <Button Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="2"
            HorizontalAlignment="Center" Margin="5"
            Command="{Binding LoginCommand}">Login</Button>
</Grid>

这里有相当多的代码,但大部分只是语法(XAML,就像它的父级 XML 一样,相当冗长)。正如您所看到的,我们在这里使用数据绑定,就像我们之前所做的那样。表单上只有一个按钮(当用户登录或关闭应用时)。

在这个阶段,我们还没有设置绑定或任何功能,所以视图不会做任何事情。我们还需要告诉 UWP 应用进入这个视图,而不是我们之前在App.xaml.cs中创建的主视图(在OnLaunched方法中):

if (e.PrelaunchActivated == false)
{
    if (rootFrame.Content == null)
    {
        // When the navigation stack isn't restored 
        // navigate to the first page,
        // configuring the new page by passing required 
        // information as a navigation parameter
        rootFrame.Navigate(typeof(LoginView), e.Arguments);
    }
    // Ensure the current window is active
    Window.Current.Activate();
}

我们在这里更改代码,而不是添加任何内容。事实上,唯一真正的变化是LoginView的文本(假设你给你的视图命名和我一样)。

让我们创建视图模型。我们将从用于主视图的相同样板代码开始:

public class LoginViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;
    private bool UpdateField<T>(ref T field, T value,
           [CallerMemberName] string propertyName = null)
    {
        if (EqualityComparer<T>.Default.Equals(field, value)) return false;
        field = value;
        OnPropertyChanged(propertyName);
        return true;
    }

    public void OnPropertyChanged([CallerMemberName] string propertyName = null)
    {
        this.PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
    }
}

If this were a production application, these two classes should inherit from a common base ViewModel. If you choose to extend this application, I would strongly advise that you start there.

接下来我们需要一些新的属性来反映用户名和密码:

private string _username;

public string Username
{
    get => _username;
    set
    {
         UpdateField(ref _username, value);
    }
}

private string _password;

public string Password
{
    get => _password;
    set
    {
         UpdateField(ref _password, value);
    }
}

我们需要的最后两件事是LoginCommand连线和数据上下文设置;先说LoginCommand:

public LoginViewModel()
{
    LoginCommand = new RelayCommand(() =>
    {
        DoLogin();
    });
}

private void DoLogin()
{
    throw new NotImplementedException();
}

public RelayCommand LoginCommand { get; set; }

让我们设置数据上下文。然后,我们应该可以看到登录屏幕上的万事万物栏实际登录(在LoginView.xaml.cs里面):

public LoginView()
{
    this.InitializeComponent();
    ViewModel = new LoginViewModel();
    DataContext = ViewModel;
}

public LoginViewModel ViewModel { get; set; }

运行这个应该会在你启动时显示登录屏幕,允许你输入用户名和密码,然后当你按下Login按钮时抛出Not Implemented异常。现在,我们可以调用 IdentityServer,获取令牌,并访问 API。

正在调用 IdentityServer

调用 IdentityServer 实际上只是填写我们在登录按钮后面创建的命令。让我们在LoginViewModel中更改我们的命令,使它看起来更像这样:

public LoginViewModel(IHttpStockClientHelper httpStockClientHelper)
{
    _httpStockClientHelper = httpStockClientHelper;
    LoginCommand = new RelayCommand(() =>
    {
        DoLogin();
    }); 
}

private async Task DoLogin()
{
    bool loggedIn = await _httpStockClientHelper.Login(Username, Password);
    if (loggedIn)
    {
        var frame = Window.Current.Content as Frame;
        frame.Navigate(typeof(MainPage), null);
    }
}

We are calling an async method from a synchronous one. The effect of this is that the code will not await the result of the operation. Should you decide to extend this application, adding a RelayCommandAsync would be a good addition; however, this will serve for our specific purpose.

如您所见,我们现在正在调用一个尚不存在的新助手方法,并且我们正在传递用户名和密码;一旦我们确定登录成功,我们就可以导航到该屏幕。

You may notice that, as I write code, a lot of times, I'll refer to methods that are yet to exist and then create them. If you practice Test-Driven Development (TDD), you start to get used to this method of working. It does have the advantage that you don't end up scaffolding a lot of infrastructure code that you'll never use.

我们现在只需要编写我们的助手方法来登录。如果使用 Ctrl-。要创建存根,它应该在IHttpClientHelper.cs中为您创建一个接口定义:

Task<bool> Login(string username, string password);

新方法(在HttpClientHelper中)将如下所示:

private static string _accessToken;

public async Task<bool> Login(string username, string password)
{ 
    var disco = await _httpClient.GetDiscoveryDocumentAsync(new DiscoveryDocumentRequest
    {
        Address = "https://localhost:5001"
    });

    var response = await _httpClient.RequestPasswordTokenAsync(new PasswordTokenRequest
    {
        Address = disco.TokenEndpoint,
        ClientId = "StockChecker",
        ClientSecret = "secret",
        Scope = "StockCheckerApi",
        UserName = username,
        Password = password
    });

    if (response.IsError)
    {
        // ToDo: Log error
        return false;
    }

    _accessToken = response.AccessToken;
    return true;
}

这里有很多,让我们一行一行地看一遍。然而,在我们这样做之前,您可能已经输入了这段代码,并意识到其中一些方法(例如,RequestPasswordTokenAsync)在HttpClient对象上不存在。这些是由IdentityModel.Client库添加的扩展方法,所以请确保您已经在文件顶部为此添加了using语句。

首先要注意的是,我们有一个令牌,它保存在类中。虽然我们现在没有使用它,但是为了调用该 API,我们稍后将需要它(这意味着我们将很快重新访问该文件)。

我们在这里做的实际上是一个三步走的过程;第一步是从 IdentityServer 获取令牌端点。发现文档是 OpenID 规范的一部分,它只是:一个告诉您在哪里可以找到身份验证服务器的所有资源的文档;它也总是在同一个(相对)位置,所以你可以去看看大型身份提供商的发现文档,比如微软、谷歌和推特;它总是在这里:

https://baseaddress/.well-known/openid-configuration

GetDiscoveryDocumentAsync给了我们一个很好的包装器,这样我们就可以在不解析 JSON 的情况下将这个文档拆开。我们已经给了它基址,现在就这样。你可能会发现,如果你选择扩展程序,你需要重新访问这个并设置Policy变量。

下一步是从刚刚给我们的端点请求一个令牌。为此,我们必须提供有效的凭据。我们现在不讨论这些设置,因为我们需要在 IdentityServer 本身中返回这些设置。现在,需要注意的重要事情是,我们正在传递用户名和密码。

最后,如果一切正常,我们将缓存令牌并返回一个标志来指示成功。

能力

UWP 应用作为可信应用分发。这意味着他们做的任何事情都必须清楚;也就是说,您需要告诉应用您需要访问什么以及您需要什么权限。这与身份权限无关,但是我们需要告诉应用它可以访问 localhost(显然,只有在我们开发的时候),我们可以使用证书,等等。这都是在Package.appxmanifest文件中实现的。如果您只需在 Visual Studio 中双击它,您将获得一个用户界面,允许您选择资产、功能、声明等;讨论这里的所有内容超出了本章的范围,但是您需要添加三项功能:

现在,一切都应该就绪,我们可以正确配置 IdentityServer,并让整个登录系统焕发生机。

设置身份服务器

设置身份服务器通常是这个难题的第一部分;然而,我觉得它更好地说明了当您首先插入其他部分时,一切是如何工作的。

要设置 IdentityServer,我们需要了解三个概念,我们已经简要地了解了它们:

  • 用户(可以访问资源的用户——在我们的例子中,露西是用户
  • 资源(他们希望访问的资源–我们的资源是我们的 API )
  • 客户端(用户试图访问系统的方法–在我们的例子中,这是我们的 UWP 应用)

为了运行,需要给 IdentityServer 一个有效的列表。通常,特别是对于用户,您会将它链接到数据库;然而,为了简单起见,我们将简单地告诉系统这些是什么。

在我们的身份服务器services.cs文件中,我们已经有了这个:

public void ConfigureServices(IServiceCollection services)
{
    services
        .AddIdentityServer()
        .AddDeveloperSigningCredential()
        .AddInMemoryClients(new List<Client>())
        .AddInMemoryIdentityResources(new List<IdentityResource>());
}

我们已经向 IdentityServer 讲述了我们的客户列表,所以让我们介绍另外两个概念:

public void ConfigureServices(IServiceCollection services)
{
    services
        .AddIdentityServer()
        .AddDeveloperSigningCredential()
        .AddInMemoryClients(IdentityServerHelper.GetClients())
        .AddInMemoryApiResources(IdentityServerHelper.GetApiResources())
        .AddTestUsers(IdentityServerHelper.GetUsers())
        .AddInMemoryIdentityResources(new List<IdentityResource>());
}

现在,我们已经介绍了我们的三个概念。随着成为标准,我们使用了一些尚不存在的方法(和一个类)。

The method to add the users is not called AddTestUsers by accident. Although what we are doing here will work, it is not very extensible, and defining a data store for the users in the system is something that should be high up on the list of things to do to extend this project.

我们的第一个方法是添加客户端:

public static class IdentityServerHelper
{
    internal static IEnumerable<Client> GetClients()
    {
        var clients = new List<Client>
        {
            new Client
            {
                ClientId = "StockChecker", 
                AllowedGrantTypes = GrantTypes.ResourceOwnerPassword, 
                ClientSecrets =
                {
                    new Secret("secret".Sha256())
                },

                AllowedScopes = { "StockCheckerApi" }
            }
        };
        return clients;
    }
}

请记住,我们说过客户端是您访问资源的方法。在我们的案例中,我们的客户是我们的 UWP 应用;然而,它可能是一个网络应用、一个控制台应用,甚至是一个用 Python 或 Go 编写的应用——它不一定是. NET

您将从客户端识别客户端标识和客户端密码(通常,您将首先设置服务器,然后将这些信息用于客户端,而不是相反,就像我们在这里所做的那样)。

我们已经设置了授权类型——我们将在稍后回到这个话题并更详细地讨论它——并且我们已经设置了范围。该作用域告诉 IdentityServer 该客户端被允许做什么。这意味着您可以让多个客户端登录到一个系统,并纯粹基于客户端来限制访问。

接下来,我们将向同一类添加资源:

internal static IEnumerable<ApiResource> GetApiResources()
{
    var resources = new List<ApiResource>
    {
        new ApiResource("StockCheckerApi", "Stock Checker API")
    };

    return resources;
}

这里的资源和客户端内部的范围是相同的概念,但是角度不同。这是服务器提供访问的所有资源的综合列表,而客户端指定它需要这些资源中的哪一个。

最后,在同一个类中,我们将添加用户:

internal static List<TestUser> GetUsers()
{
    var users = new List<TestUser>
    {
        new TestUser
        {
            SubjectId = "1",
            Username = "Lucy",
            Password = "password123"
        },
        new TestUser
        {
            SubjectId = "2",
            Username = "Morris",
            Password = "password123"
        },
        new TestUser
        { 
            SubjectId = "3",
            Username = "Graham",
            Password = "password123"
        }
    };

    return users;
}

在这一章的开始,我们给出了一个场景,在这个场景中,公司需要考虑四个不同的人,正如你所看到的,有三个用户。如果你回去,你会发现其实是山姆不见了。Sam 不在公司工作,是客户,因此不需要访问系统。然而,她是一个重要的概念;也就是说,间接与系统交互但不需要访问的用户。

If you are thinking of extending this project, then you might consider this: as a customer, Sam may like to access a web portal, but not the UWP application. As a result, in addition to a proper user store, as we mentioned earlier, you would need to create a website and add that as a client.

好了,我们的设置完成了。如果你现在运行这三个项目,你应该可以输入露西的凭据(她的密码是password123)并让他们登录。

你也应该能够看到他们被拒绝登录,例如,如果你尝试了password1的密码。

调用应用编程接口

您现在可以登录了;但是,如果您试图访问任何资源,您会发现系统抛出了一个异常。原因是我们保护了 API,这意味着当我们调用 API 时,我们需要传递一个令牌来证明我们是我们所说的那个人。在HttpClientHelper.cs内部,我们可以简单地添加一个对将传递承载令牌的方法的调用:

public async Task<int> GetQuantityAsync(int productId)
{
    string path = $"api/stock/{productId}";
    _httpClient.SetBearerToken(_accessToken);
    string quantityString = await _httpClient.GetStringAsync(path);
    return int.Parse(quantityString);
}

最后,我们可以为update方法添加一个类似的行,我们的 API 应该安全地工作:

public async Task UpdateQuantityAsync(int productId, int newQuantity)
{
    string path = $"api/stock/{productId}";
    _httpClient.SetBearerToken(_accessToken);
    var httpContent = new StringContent(newQuantity.ToString());
    httpContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");

    await _httpClient.PutAsync(path, httpContent);
}

因此,我们的身份系统现在验证用户在我们的系统中是有效的。然而,我们仍然有一个问题:我们的用户可以访问系统的所有功能。我们需要锁定这一点,我们会的,但让我们先收拾一些残局。

授权类型

我们的第一个问题是赠款类型。我们不会改变这一点,但我们会调查为什么我们应该(或者至少为什么我们应该考虑改变它)。我们使用的授权类型是资源所有者密码;这使我们能够获取用户的用户名和密码,然后将其连同密钥一起发送给 IdentityServer。然后,我们获得一个令牌,我们可以使用该令牌与我们的资源进行通信。在我们的例子中,资源是一个应用编程接口。这是一个安全的系统在某种程度上

让我们戴上我们的黑帽子,想一想我们可能如何妥协这样一个系统。请记住,我们处理的是桌面软件.NET,正如我们之前所说的,对. NET 程序集或可执行文件进行逆向工程是极其容易的。我们将秘密存储在编译后的代码中,因此攻击者可以访问该秘密。当然,没有用户名和密码,这并不能访问任何东西。

这种授权类型的另一个问题是,如果我们引入第二个访问点(假设我们决定为构建者 Sam 开发一个门户网站,如果您忘记了她是谁,请参见前面的部分),我们将需要创建另一个屏幕来接受用户名和密码。

那么,解决办法是什么?

解决这个问题的一种方法是在桌面应用中托管一个网页。这样,即使我们在桌面上,处理安全性的代码也托管在服务器上。Windows 10 为此提供了一个网络身份验证代理

When dealing with security, it should always be remembered that there is no such thing as totally secure. There are always ways to get into a system – no matter how locked down you make it, it is always possible to get in: you should think of it a little like securing your house. Different houses have different levels of security: your house probably has a door – just closing your door offers more security than leaving it wide open; locking the door gives more security still; having multiple locks more security still; and a reinforced door even more. However, banks have vaults, with dozens of locks and keys and security guards, alarms, and so forth, and yet if I told you someone had robbed a bank, you wouldn't think of it as a unique thing to happen.

因此,安全性是一种权衡:您试图保护的东西有多有价值(如果您的系统遭到破坏,可能发生的最糟糕的事情是什么?),如果实施这种保护,系统的可用性如何,增加这种保护的成本如何?

在我们的例子中,因为我们有一个非常具体和有限的要求,我们将保持我们的赠款类型不变。然而,如果你想改进这个系统,这是一个很好的扩展点。

创建和使用有效密钥

最后一个问题是我们的关键。我们目前正在使用开发密钥。这在我们编写软件时非常有效,但是我们显然需要在系统发货之前生成一个真实的密钥。虽然生成生产证书不属于本章的范围,但我们将快速介绍如何生成和使用自签名证书。

This solution is not meant for production. Before deploying to production, you should get a certificate from a certificate provider. There are several such providers and some (at least one that I'm aware of) provide a free certificate.

为了生成我们的证书,让我们生成它。首先启动 Windows PowerShell(确保以管理员身份执行此操作),然后输入以下命令:

> New-SelfSignedCertificate -Subject "CN=testcert" -KeySpec "Signature" -CertStoreLocation "Cert:\CurrentUser\My"

这将在您的证书存储中生成个人证书。一旦生成,它会给你一个Thumbprint-记下这个值,因为几分钟后你就需要它了:

如果你想看这个,那么你可以轻松地看。如果您正在运行 Windows 10,请按 Windows 键并键入以下内容:

mmc.exe

这将打开管理控制台。在这里,您可以选择个人存储并查看您的所有证书:

我们需要稍微修改一下代码。在 IdentityServer startup.cs文件中,更改ConfigureServices方法,使其如下所示:

public void ConfigureServices(IServiceCollection services)
{
    X509Certificate2 x509Certificate2 = null;
    using (var certStore = new X509Store(StoreName.My, StoreLocation.CurrentUser))
    {
        certStore.Open(OpenFlags.ReadOnly);
        var certCollection = certStore.Certificates.Find(
        X509FindType.FindByThumbprint,
        "CED666617B2C3E4C244B38EC3BB322191148EA92", // Thumbprint
        false);

        if (certCollection.Count == 0)
            throw new Exception("No certificate found");

        x509Certificate2 = certCollection[0];
    }

    services
        .AddIdentityServer()
        //.AddDeveloperSigningCredential()
        .AddSigningCredential(cert)
        .AddInMemoryClients(IdentityServerHelper.GetClients())
        .AddInMemoryApiResources(IdentityServerHelper.GetApiResources())
        .AddTestUsers(IdentityServerHelper.GetUsers())
        .AddInMemoryIdentityResources(new List<IdentityResource>());
}

如您所见,我们正在证书存储中查找我们之前提到的指纹。如果我们没有找到任何东西,那么我们就崩溃了(这比一个错误更可取)。最后,我们将AddDeveloperSigningCredential更改为普通的旧AddSigningCredential,我们将证书传递给它。虽然证书不适合生产,但这更接近生产代码。

现在,我们将了解如何检查用户的凭据,以允许他们访问应用的不同部分。

批准

授权是您在用户通过身份验证后应用于用户的策略。也就是说,我们现在知道用户是谁:至少,我们知道他们有有效的用户名和密码,并且他们使用的是经过批准的客户端。下一步是确保每个用户只能访问系统的正确部分。

Some of these permissions may not be completely realistic, but they do have the advantage of covering the various possibilities. A quick note on PolicyServer: PolicyServer (found here: https://policyserver.io/) is a framework, written by the same people that created IdentityServer. It offers very similar functionality. If you are intending to extend this application, then I would strongly encourage you to consider using it. It is an open source and commercial offering.

这种更改有三个部分:更改用户以获得相关权限,更改基础结构以传递这些权限,以及更改客户端以显示正确的控件。

用户和角色

本质上,为了让我们的应用为我们不同的用户服务,我们将引入角色的概念。这是处理权限时非常常见的概念;这意味着,我们可以为每个用户分配一个角色,并基于此授予权限,而不是创建一个标识用户名“Graham”的代码路径,然后启用所有功能。

我把这看作是授权的棍棒屋(这是指一个关于三只小猪的儿童故事,他们试图通过建造不同类型的房子来保护自己免受狼的伤害)。稻草屋(也就是最不可扩展的)是每次都显式地检查特定的用户(例如,检查用户名是否是 Graham)。砖房(也就是最可扩展的)是进一步的抽象,在这里您引入了策略的概念;每个角色可能有一个或多个策略,并且是策略控制可以访问的内容。

让我们从这里开始,给每个用户一个角色。在IdentityServerHelper.cs中,我们目前有一个名为GetUsers的方法;我们将在此添加角色:

internal static List<TestUser> GetUsers()
{
    var users = new List<TestUser>
    {
        new TestUser
        {
            SubjectId = "1",
            Username = "Lucy",
            Password = "password123",
            Claims = new List<Claim>()
            {
                new Claim(JwtClaimTypes.Role, "Sales")
            }
        },
        new TestUser
        {
            SubjectId = "2",
            Username = "Morris",
            Password = "password123",
            Claims = new List<Claim>()
            {
                new Claim(JwtClaimTypes.Role, "Maintenance")
            }
        },
        new TestUser
        { 
            SubjectId = "3",
            Username = "Graham",
            Password = "password123",
            Claims = new List<Claim>()
            {
                new Claim(JwtClaimTypes.Role, "Administrator")
            }
        }
    };
    return users;
}

这里有很多代码,但是如您所见,只有五六行新代码:我们只是向用户分配一组新的声明,每个声明都有一个特定的角色。

As we mentioned earlier, this is not an ideal way to store the users, but it does mean that as we make changes such as this, it's obvious what we've changed. If these users were held in a database, then the change would be obscured.

不幸的是,我们不能只是将新信息分配给用户,并让它立即传播:我们需要许多支持性的更改。

标识服务器

下一站是我们的客户。我们需要告诉 IdentityServer 客户端被允许访问额外的资源;在IdentityServerHelper.cs文件内部,我们将更改GetClients方法,使其如下所示:

internal static IEnumerable<Client> GetClients()
{
    var clients = new List<Client>
    {
        new Client
        {
            ClientId = "StockChecker", 
            AllowedGrantTypes = GrantTypes.ResourceOwnerPassword, 
            ClientSecrets =
            {
                new Secret("secret".Sha256())
            },
            AllowedScopes =
            {
                "StockCheckerApi",
                "roles",
                IdentityServerConstants.StandardScopes.OpenId
            }
        }
    };
    return clients;
}

同样,我们在这里只添加了两行新代码;我们已经指定我们可以返回OpenId和一个名为roles的新资源。

OpenId is a standardized method of dealing with identification. In order to return anything at all about the user, we need to specify this.

既然我们已经说了可以返回一个叫做roles的东西,我们来定义一下这是什么;这在这个文件中采用了新的 helper 方法的形式(我们将在下一节中调用它):

internal static IEnumerable<IdentityResource> GetIdentityResources()
{
    return new List<IdentityResource> 
    {
        new IdentityResource 
        {
            Name = "roles",
            UserClaims = new List<string> { JwtClaimTypes.Role } 
        },
        new IdentityResources.OpenId()
    };
}

到目前为止,我们只看到了一个ApiResource;然而,在这里,我们声明我们将返回一个与身份相关的资源。事实上,我们将返回两个:OpenId和我们新的roles资源。

让我们快速重温一下IdentityServer中的startup.cs文件;ConfigureServices方法需要更改,如下所示:

services
    .AddIdentityServer()
    //.AddDeveloperSigningCredential()
    .AddSigningCredential(x509Certificate2)
    .AddInMemoryClients(IdentityServerHelper.GetClients())
    .AddInMemoryApiResources(IdentityServerHelper.GetApiResources())
    .AddTestUsers(IdentityServerHelper.GetUsers())
    .AddInMemoryIdentityResources(IdentityServerHelper.GetIdentityResources());
}

这里不包括整个方法,但是我们只改变了一行:AddInMemoryIdentityResources()现在有了我们新的助手方法传入其中。

仅此而已。现在,让我们来看看需要什么样的客户端变化(到目前为止这种变化最大的部分)。

客户

在这里,我们将更新客户端,以便用户只能看到与他们相关的功能。

It's worth noting that the changes we're making here do not prevent a user from manually calling the API and performing functions that are not available on the screen. Again, this project is a starting point, and when extending it, you should think carefully about what you are protecting and who you are protecting it from.

在客户端更改中,有三个阶段:逻辑更改以实际允许用户查看和更改他们有权访问的控件,对服务器调用的更改以带回额外的数据,最后,需要从服务器检索一些额外的信息并将其传递给相关的视图模型。让我们从逻辑变化开始。

逻辑更改和用户界面更改

让我们从 UWP 申请的MainPage.xaml文件开始。目前,这里只有一个变化(我们将有一秒钟,稍后再回来)。我们需要做的是当用户没有权限查看时,使数量不可见。定位 XAML 内的TextBox数量:

<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding Quantity, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Visibility="{Binding CanViewQuantity, Converter={StaticResource BooleanToVisibilityConverter}}"/>

谁能想到这么少量的代码会引发这么多问题?然而,答案很简单:我们还没有这里提到的任何新东西——它们正在出现。然而,我们确实需要迅速讨论这里到底会发生什么。

使用数据绑定的一个主要优势是,您可以将业务逻辑(用 MVVM 的话来说,就是视图模型)与视图分开。这里,我们绑定到一个叫做CanViewQuantity的布尔属性;然而,我们希望将其绑定到我们控件的Visible属性。我们可以让CanViewQuantity返回一个枚举的Visibility对象(视图可以直接理解),这是可行的;然而,我们将永远无法在 Windows 环境之外使用该模型。因此,我们需要创建一个转换器。

In fact, if you use the new x:Bind syntax, BooleanToVisibility conversion is now baked into the system; this is, however, only available in Windows 10 since release 1607. Check out the following link for further details: https://docs.microsoft.com/en-us/windows/uwp/xaml-platform/x-bind-markup-extension.

现在让我们创建一个新文件并命名为BooleanToVisibilityConverter:

class BooleanToVisibilityConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, string language)
    {
        return (bool)value ? Visibility.Visible : Visibility.Collapsed;
    }

    public object ConvertBack(object value, Type targetType, object parameter, string language)
    {
        throw new NotImplementedException();
    }
}

This, along with other useful extensions, tools, and controls can also be found in the Windows Community Toolkit: https://docs.microsoft.com/en-gb/windows/communitytoolkit/.

这个代码文件真的不值得解释;我们只是返回Visible,其中布尔值为真。值得记住的是,转换可以像您需要的那样复杂,尽管您应该避免将业务逻辑放在这里:它应该始终是将原始类型绑定到复杂视图概念的一种方式(例如truevisible)。

让我们简单回到MainPage.Xaml。在我们继续之前,我们需要声明我们希望使用转换器;声明部分应该类似于这样:

<Page
    x:Class="StockChecker.UWP.MainPage"

    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:StockChecker.UWP"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d"
    Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"
    xmlns:converters="using:StockChecker.UWP.Converters">
    <Page.Resources>
        <converters:BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
    </Page.Resources>

我们在这里添加了两件事:一个包含converters文件的 XML 名称空间引用(xmlns)和我们希望用作资源的特定转换器。

我们现在已经完成了 XAML,所以让我们看看我们需要在视图模型中改变什么。MainPageViewModel需要三个新属性:用户角色、用户是否应该可以查看数量、用户是否应该可以更新数量。让我们先添加局部变量:

private bool _canViewQuantity;
private bool _canUpdateQuantity;
private string _userRole;

CanViewQuantityCanUpdateQuantity是非常简单的属性:

public bool CanUpdateQuantity
{
    get => _canUpdateQuantity;
    set
    {
        UpdateField(ref _canUpdateQuantity, value);
    } 
}

public bool CanViewQuantity
{
    get => _canViewQuantity;
    set
    {
        UpdateField(ref _canViewQuantity, value);
    } 
}

然而,在UserRole中,我们将决定用户可以做什么:

public string UserRole
{
    get => _userRole;
    set
    {
        if (UpdateField(ref _userRole, value))
        {
            CanViewQuantity = UserRole == "Administrator" || UserRole == "Sales";
            CanUpdateQuantity = UserRole == "Administrator" || UserRole == "Maintenance";
        }
    }
}

This is a very simple example; should you wish to make it more complex, I would strongly recommend that you extract this logic into a separate class or even a separate library that is responsible for permissions. One possible way to address this is to use decorators.

视图模型中只剩下一个变化,那就是确保“更新数量”按钮只对那些有权限的人启用。构造函数应该这样更改:

public MainPageViewModel(IHttpStockClientHelper httpClientHelper)
{
    _httpClientHelper = httpClientHelper;
    UpdateQuantity = new RelayCommand(async () =>
    {
        await _httpClientHelper.UpdateQuantityAsync(ProductId, Quantity);
        await RefreshQuantity(); 
    }, 
    () => Quantity != _originalQuantity && CanUpdateQuantity); 
}

我们在这里所做的只是添加一个额外的检查,这样命令(以及按钮)只有在CanUpdateQuantity为真时才被启用。

接下来,我们将更改登录过程以获取附加信息,并将其传递给视图模型。

登录和导航更改

登录视图本身不需要更改;但是,我们需要更改登录视图模型,以便我们可以检索与用户相关的数据。在LoginViewModel.cs中,我们将更改DoLogin()方法,如下所示:

private async Task DoLogin()
{
    bool loggedIn = await _httpStockClientHelper.Login(Username, Password);
    if (loggedIn)
    {
        string userRole = await _httpStockClientHelper.GetUserRole();
        var frame = Window.Current.Content as Frame;
        frame.Navigate(typeof(MainPage), userRole);
    }
}

我们在这里做了两件事:首先,我们调用了 helper 类上的一个新方法(方法本身很快就会被调用),其次,我们将这个信息传递到MainPage中。这个变化的第二部分在后面的MainPage代码(MainPage.xaml.cs)中,我们将在这里添加一个新的方法:

protected override void OnNavigatedTo(NavigationEventArgs e)
{
    base.OnNavigatedTo(e);
    var viewModel = DataContext as MainPageViewModel;
    viewModel.UserRole = e.Parameter.ToString();
}

在构造函数中,我们将视图的数据上下文设置为视图模型。因此,我们可以简单地将数据上下文转换回视图模型。

Having too much code in the code is usually an indication that the project doesn't have a good base architecture and that the business logic and UI are too tightly coupled. As you can see here, we are coupling the two. If you must breach their separation, then referencing the ViewModel from the view is the better approach.

我们需要对逻辑和导航进行的更改到此结束。我们最后一个变化是服务器调用。

服务器调用

这里最明显的变化是我们需要一种新的方法。我们之前引用过它,所以代码不会像现在这样编译。如果选择无法识别的方法调用并使用 Ctrl-。,你应该看到界面中已经创建了如下的方法定义(或者你可以简单的从这里复制到IHttpClientHelper.cs):

Task<string> GetUserRole();

这个新方法的实现在HttpClientHelper.cs中,需要如下所示:

public async Task<string> GetUserRole()
{
    var userInfo = await _httpClient.GetUserInfoAsync(new UserInfoRequest()
    {
        Address = _discoveryResponse.UserInfoEndpoint,
        Token = _accessToken
    });

    string role = userInfo.Claims.First(a => a.Type == JwtClaimTypes.Role).Value;
    return role;
}

GetUserInfoAsync()是 IdentityServer 4 库的扩展方法。本质上,它允许您获得一些关于认证用户的信息。这叫为什么我们需要允许OpenId

这将返回除了关于用户的信息之外,他们拥有的任何声明,这是我们的角色定义。

It's worth bearing in mind that information about a user should be about that user. For example, a user's age, gender, hair color, and role are all examples of information about the user, whereas whether or not the user has access to update the quantity field is most emphatically not information about the user; it is a logical decision based on that information.

在我们结束之前,我们只需要对同一个文件做一些小的修改。首先,我们需要改变Login方法:

public async Task<bool> Login(string username, string password)
{ 
    _discoveryResponse = await _httpClient.GetDiscoveryDocumentAsync(new DiscoveryDocumentRequest
    {
        Address = "https://localhost:5001", 
        Policy =
        { 
            ValidateIssuerName = false,
        } 
    });

    var response = await _httpClient.RequestPasswordTokenAsync(new PasswordTokenRequest
    {
        Address = _discoveryResponse.TokenEndpoint,
        ClientId = "StockChecker",
        ClientSecret = "secret",
        Scope = "openid roles StockCheckerApi",
        UserName = username,
        Password = password
    });

    if (response.IsError)
    {
        // ToDo: Log error
        return false;
    }

    _accessToken = response.AccessToken;
    return true;
}

其实这里真的只有两个变化。第一个在范围内——我们声明我们想要返回关于用户和角色的信息。第二,我们已经重命名了发现响应变量。事实上,我们将给出这个变量的类级范围:

static DiscoveryResponse _discoveryResponse;

这只是为了在我们发出最初的发现呼叫后,能够保留信息。

就这样。现在,如果您运行该应用,您应该会发现一切都如预期的那样工作...只有一个小小的例外。

看不到数量如何更新

事实证明,我们这里的逻辑有一个小故障:我们的看管人莫里斯需要能够更新股票,但不能查看当前水平。然而,我们的程序为他隐藏了股票数字,所以他不能更新它。为了解决这个问题,我们需要添加一些额外的命令和按钮。本质上,我们希望莫里斯能够使用一个库存项目,所以减少库存按钮将是理想的;现在让我们将按钮添加到MainPage.xaml中(我们将它放在Update Quantity按钮的正下方):

    <Button Command="{Binding UpdateQuantity}"
         Grid.Row="2" Grid.Column="0">
        <TextBlock Text="Update Quantity" />
    </Button>
    <Button Command="{Binding DecreaseQuantity}"
         Grid.Row="2" Grid.Column="1">
        <TextBlock Text="Decrease Quantity" />
    </Button>
</Grid>

我们可以将这个新命令添加到我们的视图模型(MainViewModel.cs)中:

public RelayCommand DecreaseQuantity { get; set; }

最后,让我们为构造函数中的新命令创建逻辑,现在应该如下所示:

public MainPageViewModel(IHttpStockClientHelper httpClientHelper)
{
    _httpClientHelper = httpClientHelper;
    UpdateQuantity = new RelayCommand(async () =>
    {
        await _httpClientHelper.UpdateQuantityAsync( 
            ProductId, Quantity);
        await RefreshQuantity(); 
    }, 
    () => Quantity != _originalQuantity && CanUpdateQuantity); 

    DecreaseQuantity = new RelayCommand(async () =>
    {
        await _httpClientHelper.UpdateQuantityAsync(
            ProductId, Quantity - 1);
        await RefreshQuantity();
    }, 
    () => Quantity > 0 && CanUpdateQuantity);
}

好吧,那我们在这里做什么?其实和更新量差不多;我们只是告诉它我们希望减少一个数量,在CanExecute中,我们检查我们至少还有一个项目。我们需要做的最后一件事是强制应用更新RefreshQuantity上的CanExecute:

private async Task RefreshQuantity()
{
    Quantity = await _httpClientHelper.GetQuantityAsync(ProductId);
    _originalQuantity = Quantity;
    UpdateQuantity.RaiseCanExecuteChanged();
    DecreaseQuantity.RaiseCanExecuteChanged();
}

同样,这只是一个额外的行,以确保我们只能在有数量要减少的地方减少数量:

既然我们已经完成了这个项目,让我们回顾一下我们在这里讨论的内容。

摘要

那是一次巨大的旅行。我们为我们的小公司设置了一个功能性应用,我们使用 IdentityServer 4 保护了访问,并使用角色实现了权限。

在这一章中,我已经说过几次了,但只是为了总结一下:当涉及到身份,甚至是一般的安全时,没有一个正确的答案。IdentityServer 在这种情况下是有意义的,因为我们使用了公司拥有和维护的应用和 API,我们需要离线访问,并且我们正在支持桌面应用。如果您只更改其中一个参数,那么使用谷歌 OAuth 或 Azure B2C 可能是有意义的。

为了重申我也多次说过的另一点:任何形式的安全都不是绝对的。您的系统可能非常安全,因为它可能使用加密流量和防火墙。但是,您可能已经对您的应用进行了渗透测试,结果出来时上面没有划痕,然后您的一名员工可能会与他人共享他们的密码。突然间,你可能就不会为这些烦恼了。

在下一章中,我们将研究如何使用创建一个 Windows 服务.NET Core 3。我们将创建一个应用,将您电脑上的照片备份到 Azure Storage 帐户。

建议的改进

如果您希望获取并改进此应用,我有几个建议(如果您一直密切关注,您可能已经注意到了其中的一些):

  1. 将凭据流更改为隐式。为此,您需要在桌面应用中托管一个 web 视图,并创建登录网站。
  2. 添加用户存储。我们的用户显然不适合生产;也就是说,除非我们能够明确保证他们永远不会雇用或失去任何工作人员。此外,在代码中以纯文本形式存储密码显然不是一个好主意(即使它在服务器上)。

  3. 执行以下任一操作:

    • 从 UWP 应用中取出 XAML 并将其转换为Xamarin.Forms,然后编译并发布一个安卓版本。
    • 获取常见的代码区域,并将它们组合起来,为您的应用创建一个小型框架。
    • 实施 MVVM 框架。大多数 MVVM 框架现在在某种程度上也支持交叉编译。
  4. 将库存系统链接到前端网站,允许用户登录并购买库存商品。
  5. 创建库存项目图像;您可以将这些图像存储在 Azure Storage 中。事实上,我们的下一章就是关于这个的。

进一步阅读

在撰写本书时,身份服务器文档处于不断变化的状态。但是,它仍然非常有用,并且包含许多示例。可以在这里找到: docs.identityserver.io

OpenID 规范可以在这里找到:https://openid.net/developers/specs/

OAuth 2 规格可以在这里找到:https://oauth.net/2/