Go to my github

《ASP.NET Core 微服务实战》-- 读书笔记(第10章)

第 10 章 应用和微服务安全

云应用意味着应用运行所在的基础设施无法掌控,因此安全不能再等到事后再考虑,也不能只是检查清单上毫无意义的复选框

由于安全与云原生应用密切相关,本章将讨论安全话题,并用示例演示几种保障 ASP.NET Core Web 应用和微服务安全的方法

云环境中的安全

内网应用

企业一直在开发这种支持性的应用,但当我们需要基于运行在可缩放的云基础设施之的 PaaS 开发此类应用时,很多旧的模式和实践将很快失效

一个最明显的问题就是无法支持 Windows 身份验证

长期以来,ASP.NET 开发人员一直沉浸在借助内置的 Windows 凭据来保障 Web 应用安全的便利中

不管是公有云平台还是私有部署的 PaaS 平台,在这些平台上,支撑应用的操作系统应被视为临时存续的

有些企业的安全策略要求所有虚拟机在滚动更新期间需要销毁并重新构建,从而缩小持续攻击的可能范围

当应用运行于 PaaS 环境中时,Cookie 身份验证仍然适用

不过它也会给应用增加额外负担

首先,Forms 身份验证要求应用对凭据进行维护并验证

也就是说,应用需要处理好这些保密信息的安全保障、加密和存储

云环境中的应用内加密

在传统 ASP.NET 应用开发中,常见的加密使用场景是创建安全的身份验证 Cookie 和会话 Cookie

在这种加密机制中,Cookie 加密时会用到机器密钥

然后当 Cookie 由浏览器发回 Web 应用时,再使用同样的机器密钥对其进行解密

如果无法依赖持久化文件系统,又不可能在每次启动应用时将密钥置于内存中,这些密钥将如何存储

答案是,将加密密钥的存储和维护视为后端服务

也就是说,与状态维持机制、文件系统、数据库和其他微服务一样,这个服务位于应用之外

Bearer 令牌

本章的示例将讲解 OAuth 和 OpenID Connect (简称 OIDC)

如果要以 HTTP 友好、可移植的方式传输身份证明,最常见的方法就是 Bearer 令牌

应用从 Authorization 请求头接收 Dearer 令牌

下例展示一个包含 Bearer 令牌的 HTTP 跟踪会话

POST /api/service HTTP/1.1
Host: world-domination.io
Authorization: Bearer ABC123HIJABC123HIJABC123HIJ Content-Type:
application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (XLL; Linux x86_64) etc...etc...etc...

Authorization 请求头的值中包含一个表示授权类型的单词,紧接着是包含凭据的字符序列

通常,服务在处理 Bearer 令牌时,会从 Authorization 请求头提取令牌

很多各式的令牌,例如 OAuth 2.0 (JWT),通常将 Base64 编码用作一种 URL 友好格式,因此验证令牌的第一步就是解码,以获取原有内容

如果令牌使用私钥加密,服务就需要使用公钥验证令牌确实由正确的发行方颁发

ASP.NET Core Web 应用安全

本章示例中,我们将主要关注 OpenID Connetc 和 JWT 格式的 Bearer 令牌

OpenID Connect 基础

OpenID Connect 是 OAuth2 的一个超集,它规定了身份提供方(IDP)、用户和应用之间的安全通信的规范和标准

使用 OIDC 保障 ASP.NET Core 应用的安全

作为本章第一个代码清单,我们将使用 OIDC 为一个简单的 ASP.NET Core
MVC Web 应用提供安全保障功能

创建一个空的 Web 应用

$ dotnet new mvc

使用 Auth0 账号配置身份提供方服务

现在可转到 http://auth0.com/,注册完成后进入面板,点击“创建客户端”按钮,请确保应用类型选择为“常规 Web 应用”

选择 ASP.NET Core 作为实现语言后,将转到一个 “快速开始”教程,其代码与本章将要编写的内容非常相似

使用 OIDC 中间件

GitHub链接:https://github.com/microservices-aspnetcore/secure-services

using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Http;


namespace StatlerWaldorfCorp.SecureWebApp
{
    public class Startup
    {
        public Startup(IHostingEnvironment env)
        {
            var builder = new ConfigurationBuilder()
                .SetBasePath(env.ContentRootPath)
                .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false)                
                .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)
        {
            services.AddAuthentication(
                options => options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme);
            
            // Add framework services.
            services.AddMvc();

            services.AddOptions();

            services.Configure<OpenIDSettings>(Configuration.GetSection("OpenID"));
        }

        // 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,
                    IOptions<OpenIDSettings> openIdSettings)
        {

            Console.WriteLine("Using OpenID Auth domain of : " + openIdSettings.Value.Domain);
            loggerFactory.AddConsole(Configuration.GetSection("Logging"));
            loggerFactory.AddDebug();

            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();                
            }
            else
            {
                app.UseExceptionHandler("/Home/Error");
            }

            app.UseStaticFiles();

            app.UseCookieAuthentication( new CookieAuthenticationOptions
            {
                AutomaticAuthenticate = true,
                AutomaticChallenge = true
            });

            var options = CreateOpenIdConnectOptions(openIdSettings);
            options.Scope.Clear();
            options.Scope.Add("openid");
            options.Scope.Add("name");
            options.Scope.Add("email");
            options.Scope.Add("picture");

            app.UseOpenIdConnectAuthentication(options);

            app.UseMvc(routes =>
            {
                routes.MapRoute(
                    name: "default",
                    template: "{controller=Home}/{action=Index}/{id?}");
            });
        }

        private OpenIdConnectOptions CreateOpenIdConnectOptions(
            IOptions<OpenIDSettings> openIdSettings)
        {
            return new OpenIdConnectOptions("Auth0")
            {
                Authority = $"https://{openIdSettings.Value.Domain}",
                ClientId = openIdSettings.Value.ClientId,
                ClientSecret = openIdSettings.Value.ClientSecret,
                AutomaticAuthenticate = false,
                AutomaticChallenge = false,

                ResponseType = "code",
                CallbackPath = new PathString("/signin-auth0"),

                ClaimsIssuer = "Auth0",
                SaveTokens = true,
                Events = CreateOpenIdConnectEvents()
            };
        }

        private OpenIdConnectEvents CreateOpenIdConnectEvents()
        {
            return new OpenIdConnectEvents()
            {
                OnTicketReceived = context =>
                {
                    var identity = 
                        context.Principal.Identity as ClaimsIdentity;
                    if (identity != null) {
                        if (!context.Principal.HasClaim( c => c.Type == ClaimTypes.Name) &&
                        identity.HasClaim( c => c.Type == "name"))
                        identity.AddClaim(new Claim(ClaimTypes.Name, identity.FindFirst("name").Value));
                    }
                    return Task.FromResult(0);
                }
            };
        }
    }
}

与之前各章代码的第一点区别在于,我们创建了一个名为 OpenIdSettings 的选项类,从配置系统读入后,以 DI 的服务方式提供给应用

它是一个简单类,其属性仅用于存储每种 OIDC 客户端都会用到的四种元信息:

  • 授权域名
  • 客户端 ID
  • 客户端密钥
  • 回调 URL

由于这些信息的敏感性,我们的 appsettings.json 文件没有签入到 GitHub,不过以下代码清单列出了它的大致格式

{
    "OpenID": {
        "Domain": "Your Auth0 domain",
        "ClientId": "Your Auth0 Client Id",
        "ClientSecret": "Your Auth0 Client Secret",
        "CallbackUrl": "http://localhost:5000/signin-auth0"
    }
}

接下来要在 Startup 类中执行的两部操作是,让 ASP.NET Core 使用 Cookie 身份验证和 OpenID Connect 身份验证

添加一个 account 控制器,提供的功能包括登录、注销、以及使用一个视图显示用户身份中的所有特征

using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Http.Authentication;
using Microsoft.AspNetCore.Authorization;
using System.Linq;
using System.Security.Claims;

namespace StatlerWaldorfCorp.SecureWebApp.Controllers
{
    public class AccountController : Controller
    {
        public IActionResult Login(string returnUrl = "/")
        {
            return new ChallengeResult("Auth0", new AuthenticationProperties() { RedirectUri = returnUrl });
        }

        [Authorize]
        public IActionResult Logout()
        {
            HttpContext.Authentication.SignOutAsync("Auth0");
            HttpContext.Authentication.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);

            return RedirectToAction("Index", "Home");
        }

        [Authorize]
        public IActionResult Claims()
        {
            ViewData["Title"] = "Claims";
            var identity = HttpContext.User.Identity as ClaimsIdentity;
            ViewData["picture"] = identity.FindFirst("picture").Value;
            return View();
        }
    }
}

Claims 视图代码,它从特征集合中逐个取出特征的类型和值,并呈现在表格中,同时,视图还显示用户头像

<div class="row">
    <div class="col-md-12">

        <h3>Current User Claims</h3>

        <br/>  
        <img src="@ViewData["picture"]" height="64" width="64"/><br/>

        <table class="table">
            <thead>
                <tr>
                    <th>Claim</th><th>Value</th>
                </tr>
            </thead>
            <tbody>
                @foreach (var claim in User.Claims)
                {
                    <tr>
                        <td>@claim.Type</td>
                        <td>@claim.Value</td>
                    </tr>
                }
            </tbody>
        </table>
    </div>
</div>

现在,我们已经基于一个模板生成的空白 ASP.NET Core Web 应用,建立了与第三方云友好的身份提供服务的连接

这让云应用能够利用 Bearer 令牌和 OIDC 标准的优势,从手工管理身份验证的负担中解放出来

OIDC 中间件和云原生

我们已经讨论过在使用 Netflix OSS 技术栈时,如何借助 Steeltoe 类库支持应用配置和服务发现

我们可以使用来自 Steeltoe 的 NuGet 模块 Steeltoe.Security.DataProtection.Redis

它专门用于将数据保护 API 所用的存储从本地磁盘迁移到外部的 Redis 分布式缓存中

在这个类库,可使用以下方式在 Startup 类的 ConfigureServices 方法中配置由外部存储支持的数据保护功能

services.AddMvc();

services.AddRedisConnectionMultiplexer(Configuration);
services.AddDataProtection()
        .PersisitKeysToRedis()
        .SetApplicationName("myapp-redis-keystore");

services.AddDistributedRedisCache(Configuration);

services.AddSession();

接着,我们在 Configure 方法中调用 app.UseSession() 以完成外部会话状态的配置

保障 ASP.NET Core 微服务的安全

本节,我们讨论为微服务提供安全保障的几种方法,并通过开发一个使用 Bearer 令牌提供安全功能的微服务演示其中的一种方法

使用完整 OIDC 安全流程保障服务的安全

在这个流程中,用户登录的流程前面已经讨论过,即通过几次浏览器重定向完成网站和 IDP 之间的交互

当网站获取到合法身份后,会向 IDP 申请访问令牌,申请时需要提供身份证令牌以及正在被请求的资源的信息

使用客户端凭证保障服务的安全

首先,只允许通过 SSL 与服务通信

此外,消费服务的代码需要在调用服务时附加凭据

这种凭据通常就是用户名和密码

在一些不存在人工交互的场景中,将其称为客户端标识和客户端密钥更准确

使用 Bearer 令牌保障服务的安全

在服务的 Startup 类型的 Configure 方法中启用并配置 JWT Bearer 身份验证

app.UseJwtBearerAuthentication(new JwtBearerOptions)
{
    AutomaticAuthenticate = true,
    AutomaticChallenge = true,
    TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuerSigningKey = true,
        IssuerSigningKey = signingKey,
        ValidateIssuer = false,
        ValidIssuer = "http://fake.issuer.com",
        ValidateAudience = false,
        ValidAudience = "http://sampleservice.example.com",
        ValidateLifetime = true,
    }
};

我们可控制在接收 Bearer 令牌期间要执行的各种验证,包括颁发方签名证书、颁发方名称、接收名称以及令牌的时效

在上面的代码中,我们禁用了颁发方和接收方名称验证,其过程都是相当简单的字符串对比检查

开启验证时,颁发方和接收方名称必须与令牌中包含的颁发方式和接收方式名称严格匹配

要创建一个密钥,用于令牌签名时所用的密钥进行对比,我们需要一个保密密钥,并从它创建一个 SymmetricSecurityKey

string SecretKey = "sericouslyneverleavethissitting in yourcode";
SymmetricSecurityKey signingKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(SecretKey));

为了消费安全的服务,我们需要创建一个简单的控制台应用,它从一组 Claim 对象生成一个 JwtSecurityToken 实例,并作为 Bearer 令牌放入 Authorization 请求头发给服务端

var claims = new []
{
    new Claim(JwtRegisteredClaimNames.Sub, "AppUser_Bob"),
    new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
    new Claim(JwtRegisteredClaimNames.Iat, ToUnixEpochDate(DataTime.Now).ToString(), ClaimValueTypes.Integer64),
};
var jwt = new JwtSecurityToken(
    issuer : "issuer",
    audience : "audience",
    claims : claims,
    notBefore : DateTiem.UtcNow,
    expires : DateTime.UtcNow.Add(TimeSpan.FromMinutes(20)),
    signingCredentials: creds)
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", encodedJwt);

var result = httpClient.GetAsync("http://localhost:5000/api/secured").Result;
Console.WriteLine(result.StatusCode);
Console.WriteLine(result.Content.ToString());

下面是一个受安全机制保护的控制器方法,它将枚举从客户端发来的身份特征

[Authorize]
[HttpGet]
public string Get()
{
    foreach (var claim in HttpContext.User.Claims){
        Console.WriteLine($"{claim.Type}:{claim.Value}");
    }
    return "this is from the super secret area";
}

如果要控制特定客户端能够访问的控制器方法,我们可以利用策略概念,策略是在授权检查过程中执行一小段代码

[Authorize( Policy = "CheeseburgerPolicy")]
[HttpGet("policy")]
public string GetWithPolicy()
{
    return "this is from the super secret area w/policy enforcement.";
}

在 ConfigureServices 方法中配置策略的过程很简单

public void ConfigureServices(IServiceCollection services){
    services.AddMvc();
    services.AddOptions();
    services.AddAuthorization( options => {
        options.AddPolicy("CheeseburgePolicy",
        policy =>
        policy.RequireClaim("icanhazcheeseburger", "true"));
    });
}

现在,只要修改控制台应用,在其中添加这种类型的特征并将值指定为 true,就既能调用普通受保护的控制器方法,又能调用标记了 CheeseburgerPolicy 策略的方法

该策略需要特定的身份特征、用户名、条件以及角色

还可以通过实现 IAuthorizationRequirement 接口定义定制的需求,这样就可以添加自定义验证逻辑而不会影响各个控制器

知识共享许可协议

本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。

欢迎转载、使用、重新发布,但务必保留文章署名 郑子铭 (包含链接: http://www.cnblogs.com/MingsonZheng/ ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。

如有任何疑问,请与我联系 (MingsonZheng@outlook.com) 。

posted @ 2020-02-11 00:03  郑子铭  阅读(258)  评论(0编辑  收藏  举报