深入浅出Blazor webassembly 之API服务端保护

摘要:
受保护的API项目的思想是,调用者首先向登录界面提交用户名和密码(即凭据),登录界面验证凭据的有效性。如果它有效,它将向调用者返回一个Jwttoken。当调用者将来访问API时,它需要将该token添加到BearerHttp头中。服务方验证令牌是否有效。如果它通过了验证,它将被允许继续访问受控制的API=====================================================本文的目标=================

受保护 API 项目的思路是:

调用方先提交用户名和密码 (即凭证) 到登录接口, 由登录接口验证凭证合法性, 如果合法, 返回给调用方一个Jwt token. 

以后调用方访问API时, 需要将该token 加到 Bearer Http 头上, 服务方验证该 token 是否有效, 如果验证通过, 将允许其继续访问受控API. 

===================================
本文目标
===================================

1. 实现一个未受保护的API

2. 网站开启 CORS 跨域共享

3. 实现一个受保护的API

4. 实现一个密码hash的接口(测试用)

5. 实现一个登录接口

===================================
目标1:  实现一个未受保护的API
===================================

VS创建一个ASP.net core Host的Blazor wsam解决方案,其中 Server端项目即包含了未受保护的 WeatherForecast API接口. 

稍微讲解一下 ASP.Net Core API的路由规则. 

下面代码是模板自动生成的,  Route 注解中的参数是 [controller], HttpGet 注解没带参数, 则该方法的url为 http://site/WeatherForecast, 

深入浅出Blazor webassembly 之API服务端保护第1张

 
VS 插件 Rest Client 访问的指令为: 
GET http://localhost:5223/WeatherForecast HTTP/1.1
content-type: application/json
稍微调整一下Route和HttpGet注解参数, 

深入浅出Blazor webassembly 之API服务端保护第2张

 VS 插件 Rest Client 访问的指令需要调整为: 

GET http://localhost:5223/api/WeatherForecast/list HTTP/1.1
content-type: application/json

===================================
目标2:  API网站开启 CORS 跨域共享
===================================

默认情况下, 浏览器安全性是不允许网页向其他域名发送请求, 这种约束称为同源策略.  需要说明的是, 同源策略是浏览器端的安全管控, 但要解决却需要改造服务端. 

究其原因, 需要了解浏览器同源策略安全管控的机制,  浏览器在向其他域名发送请求时候, 其实并没有做额外的管控, 管控发生在浏览器收到其他域名请求结果时, 浏览器会检查返回结果中,  如果结果包含CORS共享标识的话, 浏览器端也会通过检查, 如果不包含, 浏览器会抛出访问失败. 

VS创建一个ASP.net core Host的Blazor wsam解决方案, wasm是托管ASP.net core 服务器端网站之内,  所以不会违反浏览器的同源策略约束. 模板项目中, 并没有开启CORS共享控制的代码

一般情况下, 我们要将blazor wasm独立部署的CDN上, 所以 api server 要开启CORS. 

Program.cs 文件中增加两个小节代码:

先为 builder 增加服务: 

builder.Services.AddCors(option =>
{
    option.AddPolicy("CorsPolicy", policy => policy
    .AllowAnyOrigin()
    .AllowAnyHeader()
    .AllowAnyMethod());
});

其次, 需要在 web request 的pipleline 增加  UseCors() 中间件, pipeline 各个中间件顺序至关重要, 

可参考官网: https://docs.microsoft.com/zh-cn/aspnet/core/fundamentals/middleware/?view=aspnetcore-5.0#middleware-order

Cors 中间件紧跟在 UseRouting() 之后即可. 

app.UseRouting();

//Harry: enable Cors Policy, must be after Routing 
app.UseCors("CorsPolicy");

===================================
目标3:  实现一个受保护的API
===================================

增加一个获取产品清单的API, 该API需要访问方提供合法的JWT token才行. 

步骤1: 增加nuget依赖包 Microsoft.AspNetCore.Authentication.JwtBearer

步骤2: 增加 product 实体类

    public class Product
    {
        public int Id { get; set; }
        public string? Name { get; set; }
        public decimal Price { get; set; }
    }

步骤3: 增加ProductsController 类

using BlazorApp1.Shared;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace BlazorApp1.Server.Controllers
{
    [ApiController] 
    [Route("[controller]")]
    [Authorize]
    public class ProductsController : ControllerBase
    {
        [HttpGet]
        public IActionResult GetProducts()
        {
            var products = new List<Product>()
            {
                new Product()
                {
                    Id = 1,
                    Name = "Wireless mouse",
                    Price = 29.99m
                },
                new Product()
                {
                    Id = 2,
                    Name = "HP printer",
                    Price = 100
                },
                new Product()
                {
                    Id = 3,
                    Name = "Sony keyboard",
                    Price = 20
                }
            };
            return Ok(products);
        }

    }

}

注意加上了 Authorize 注解后访问url, 得到 500 报错,  提示需要加上相应的 Authorization 中间件. 

 深入浅出Blazor webassembly 之API服务端保护第3张

app 增加 Authorization 中间件  app.UseAuthorization() 后, 测试包 401 错误, 说明授权这块功能已经OK. 

测试效果图: 

深入浅出Blazor webassembly 之API服务端保护第4张

 [Authorize]  注解的说明:

  •  [Authorize] 不带参数: 只要通过身份验证, 就能访问
  •  [Authorize(Roles="Admin,User")],  只有 jwt token 的 Role Claim 包含 Admin 或 User 才能访问, 这种方式被叫做基于role的授权
  • [Authorize(Policy="IsAdmin"] , 称为基于Claim的授权机制. 它属于基于Policy策略的授权的简化版, 简化版的Policy 授权检查是看Jwt token中是否包含 IsAdmin claim,  如包含则授权验证通过.
  •  [Authorize(Policy="UserOldThan20"],   基于Policy策略的授权机制, 它是基于 claim 授权的高级版, 不是简单地看 token是否包含指定的 claim, 而是可以采用代码逻辑来验证, 实现较为复杂, 需要先实现 IAuthorizationRequirement 和 IAuthorizationHander 接口. 
  • 基于资源的授权, 这种机制更灵活,  参见 https://andrewlock.net/resource-specific-authorisation-in-asp-net-core/

不管是基于Role还是基于Claim还是基于Policy的授权验证,  token中都需要带有特定claim, token内的信息偏多, 带来的问题是: 服务端签发token较为复杂, 另外, token 中的一些信息很可能过期, 比如服务端已经对某人的角色做了修改, 但客户端token中的角色还是老样子, 两个地方的role不一致, 使得授权验证更复杂了. 

我个人推荐的做法是, API 仅仅加上不带参数的  [Authorize] , 指明必须是登录用户才能访问, 授权这块完全控制在服务端, 从token中提取userId, 然后查询用户所在的 userGroup 是否具有该功能.  这里的 userGroup 和 role 完全是一回事.  accessString 和功能点是1:n的关系, 最好是能做到 1:1. 

深入浅出Blazor webassembly 之API服务端保护第5张

 下面代码是我推荐方案的伪代码, 同时也展现 Claim / Claims /ClaimsIdentity /ClaimsPrincipal 几个类的关系:  

        [HttpGet]  
[Authorize]
public IActionResult get(int productId) { //构建 Claims 清单 const string Issuer = "https://gov.uk"; var claims = new List<Claim> { new Claim(ClaimTypes.Name, "Andrew", ClaimValueTypes.String, Issuer), new Claim(ClaimTypes.Surname, "Lock", ClaimValueTypes.String, Issuer), new Claim(ClaimTypes.Country, "UK", ClaimValueTypes.String, Issuer), new Claim("ChildhoodHero", "Ronnie James Dio", ClaimValueTypes.String) }; //生成 ClaimsIdentity 对象 var userIdentity = new ClaimsIdentity(claims, "Passport"); //生成 ClaimsPrincipal 对象, 一般也叫做 userPrincipal var userPrincipal = new ClaimsPrincipal(userIdentity); object product = loadProductFromDb(productId); var hasRight = checkUserHasRight(userPrincipal, resource:product, acccessString: "Product.Get"); if (!hasRight) { return new UnauthorizedResult(); //返回401报错 } else { return Ok(product); } } private bool checkUserHasRight(ClaimsPrincipal userPrincipal, object resource, string accessString) { throw new NotImplementedException(); // 自行实现 } private object loadProductFromDb(int id) { throw new NotImplementedException(); // 自行实现 }

===================================
目标4: 实现一个生成密码hash的接口(测试用)
===================================

这个小节主要是为登录接口做数据准备工作.  用户的密码不应该是明文形式保存, 必须存储加密后的密码. 

一般的 Password hash 算法, 需要我们自己指定 salt 值, 然后为我们生成一个哈希后的密码摘要. 校验密码时候, 需要将最初的salt值和用户传入的原始密码, 通过同样的哈希算法, 得到另一个密码摘要, 如果两个密码摘要一致, 表明新传入的原始密码是对的. 

Asp.net core提供的默认 PasswordHasher 类, 提供了方便而且安全的密码hash算法, 具体的讨论见 https://stackoverflow.com/questions/20621950/  ,   PasswordHasher 类 Rfc2898算法, 不需要我们指定 salt 值, 有算法本身生成一个随机的salt值,  并将该随机的 salt 值存在最终的密码hash中的前一部分, 所以验证时也不需要提供该salt 值.

该算法的特点是:

  • 使用非常简单, 做hash之前不需要准备 salt 值, 加密之后也不需要额外保存salt值, 
  • 同一个明文,多次做hash摘要会得到不同的结果. 

下面是一个测试 controller 用于生成密码hash值: 

using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; 

namespace BlazorApp1.Server.Controllers
{
    [ApiController] 
    [Route("[controller]")]

    public class TestController : ControllerBase
    {
        private readonly IConfiguration _configuration;
        public TestController(IConfiguration configuration)=>_configuration = configuration;

        [HttpPost("GenerateHashedPwd")]
        public string Generate([FromBody] string plainPassword)
        { 
            var passwordHasher=new PasswordHasher<String>();
            var hashedPwd = passwordHasher.HashPassword("",plainPassword);
            var verifyResult = passwordHasher.VerifyHashedPassword("", hashedPwd, plainPassword);
            Console.WriteLine(verifyResult);
            return hashedPwd;
        } 
 
    }

}

Rest client 指令:

POST http://localhost:5223/Test/GenerateHashedPwd HTTP/1.1
content-type: application/json

"123abc"

得到的hash值为: 

AQAAAAEAACcQAAAAEGVtM0HmzqITBdnkZNzbdDwM3u7zz2F5XQfRIJN/78/UGM9u8Lqcn/eh4zWlUbbDmQ==

 ===================================
目标5:  实现登录API
=================================== 

(1) appsettings.json 配置文件中, 新增 Credentials 清单,  代表我们的用户库. 

使用上面的密码hash接口, password 明文为  test-password, 对应的密文为: 
AQAAAAEAACcQAAAAENsLEigZGIs6kEdhJ7X1d7ChFZ4TKQHHYZCDoLSiPYy/GpYw4lmMOalsn8g/7debnA==
 
为了简单起见, 我们在 appsettings.json  仅新增一个 Credentials: 
  "Credentials": {
    "Email": "user@test.com",
    "Password": "AQAAAAEAACcQAAAAENsLEigZGIs6kEdhJ7X1d7ChFZ4TKQHHYZCDoLSiPYy/GpYw4lmMOalsn8g/7debnA=="
  }

(2) appsettings.json 配置文件中, 增加 jwt 配置项, 用于jwt token的生成和验证. 

jwt token 的生成是由新的 LoginController 实现, 

jwt token的验证是在 ASP.net Web的 Authentication 中间件完成的. 

  "Jwt": {
    "Key": "ITNN8mPfS2ivOqr1eRWK0Rac3sRAchQdG8BUy0pK4vQ3",",
    "Issuer": "MyApp",
    "Audience": "MyAppAudience",
    "TokenExpiry": "60" //minutes
  }

 (3) 增加 Credentials 类, 用来传入登录的凭证信息. 

public class Credentials
{ 
    [Required]
    public string Email { get; set; }
    [Required]
    public string Password { get; set; }
}

(4) 增加一个登录结果类 LoginResult:

public class LoginResult
{  
    public string? Token { get; set; }
    public string? ErrorMessage { get; set; }
}

 (5) 新增 LoginController API类 

using BlazorApp1.Shared;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;

namespace BlazorApp1.Server.Controllers
{
    [ApiController] 
    [Route("[controller]")]

    public class LoginController : ControllerBase
    {
        private readonly IConfiguration _configuration;
        public LoginController(IConfiguration configuration)=>_configuration = configuration;

        [HttpPost("login")]
        public LoginResult Login(Credentials credentials)
        {
            var passed=ValidateCredentials(credentials);
            if (passed)
            {
                return new LoginResult { Token = GenerateJwt(credentials.Email), ErrorMessage = "" };
            }
            else
            {
                return new LoginResult { Token = "", ErrorMessage = "Wrong password" };
            }
        }

        bool ValidateCredentials(Credentials credentials)
        {
            var user = _configuration.GetSection("Credentials").Get<Credentials>();
            var password = user.Password;
            var plainPassword = credentials.Password;
            var passwordHasher =new PasswordHasher<string>();
            var result= passwordHasher.VerifyHashedPassword(null, password, plainPassword);
            return (result == PasswordVerificationResult.Success);
        }

        private string GenerateJwt(string email)
        {
            var jwtKey = Encoding.UTF8.GetBytes(_configuration["Jwt:Key"]);
            var securtiyKey = new SymmetricSecurityKey(jwtKey);
            var issuer = _configuration["Jwt:Issuer"];
            var audience=_configuration["Jwt:Audience"];
            var tokenExpiry = Convert.ToDouble( _configuration["Jwt:TokenExpiry"]);

            var token = new JwtSecurityToken(
                issuer: issuer,
                audience: audience,
                expires: DateTime.Now.AddMinutes(tokenExpiry),
                claims: new[] { new Claim(ClaimTypes.Name, email) },
                signingCredentials: new SigningCredentials(securtiyKey, SecurityAlgorithms.HmacSha256)
                );
            var tokenHandler = new JwtSecurityTokenHandler(); 

            return tokenHandler.WriteToken(token);
        }
 
    }

}

 代码说明: 

  • JwtSecurityToken 类的 claims 数组参数,  对应的是 JWT token payload key-value, 一个 claim 对应一个key-value, 可以指定多个claim, 这样 jwt token的 payload 会变长. 

          代码中的 JwtSecurityToken 类的  claims 参数, 其传入值为 new[] { new Claim(ClaimTypes.Name, email) } , 说明 payload 仅有一个 claim 或者叫 key-value对,  其 key 为 name, value为邮箱号;  如果jwt token中要包含用户的 Role, 可以再增加  new Claim(ClaimTypes.Role, "Admin")

  • JwtSecurityTokenHandler 类其实很关键, 可以将 Token 对象转成字符串, 也可以用它验证 token 字符串是否合法. 

 (5)  app 增加 Authentication 中间件

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.IdentityModel.Tokens;
using System.Text;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddControllersWithViews();
builder.Services.AddRazorPages();

//Harry: Add Cors Policy service
builder.Services.AddCors(option =>
{
    option.AddPolicy("CorsPolicy", policy => policy
    .AllowAnyOrigin()
    .AllowAnyHeader()
    .AllowAnyMethod());
});


//Harry: Read Jwt settings
var jwtIssuser = builder.Configuration["Jwt:Issuer"];
var jwtAudience = builder.Configuration["Jwt:Audience"];
var jwtKey = Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]);
var securtiyKey = new SymmetricSecurityKey(jwtKey);

//Harry: Add authentication service
builder.Services.AddAuthentication("Bearer").AddJwtBearer(options => {
    options.TokenValidationParameters = new TokenValidationParameters
    {
        //验证 Issuer
        ValidateIssuer = true,
        ValidIssuer = jwtIssuser,

        //验证 Audience
        ValidateAudience = true,
        ValidAudience = jwtAudience,

        //验证 Security key
        ValidateIssuerSigningKey = true,
        IssuerSigningKey = securtiyKey,

        //验证有效性
        ValidateLifetime = true, 
        LifetimeValidator = (DateTime? notBefore, DateTime? expires, SecurityToken securityToken,
                                     TokenValidationParameters validationParameters) =>
        {
            return expires<=DateTime.Now;
        }         
    };
});


var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseWebAssemblyDebugging();
} 
else
{
    app.UseExceptionHandler("/Error");
}

app.UseBlazorFrameworkFiles();
app.UseStaticFiles();

app.UseRouting();

//Harry: enable Cors Policy, must be after Routing 
app.UseCors("CorsPolicy");

//Harry: authentication and authorization middleware to pipeline. must be after Routing/Cors and before EndPoint configuation
app.UseAuthentication();

//Harry: add authorization middleware to pipeline. must be after Routing/Cors and before EndPoint configuation
app.UseAuthorization();

app.MapRazorPages();
app.MapControllers();
app.MapFallbackToFile("index.html");


app.Run();

 Rest client测试代码:

GET http://localhost:5223/Products HTTP/1.1
content-type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoidXNlckB0ZXN0LmNvbSIsImV4cCI6MTYzNTAwNTcyMywiaXNzIjoiTXlBcHAiLCJhdWQiOiJNeUFwcEF1ZGllbmNlIn0.6rGq0Ouay9-3bvTDWVEouCHg4T7tDv129PQTha4GhP8

测试结果:

深入浅出Blazor webassembly 之API服务端保护第6张

===================================

参考
===================================

https://www.mikesdotnetting.com/article/342/managing-authentication-token-expiry-in-webassembly-based-blazor
https://chrissainty.com/avoiding-accesstokennotavailableexception-when-using-blazor-webassembly-hosted-template-with-individual-user-accounts/
https://www.puresourcecode.com/dotnet/blazor/blazor-using-httpclient-with-authentication/
https://code-maze.com/using-access-token-with-blazor-webassembly-httpclient/#accessing-protected-resources
https://andrewlock.net/resource-specific-authorisation-in-asp-net-core/
https://www.cnblogs.com/wjsgzcn/p/12936257.html

https://www.cnblogs.com/ittranslator/p/making-http-requests-in-blazor-webassembly-apps.html

免责声明:文章转载自《深入浅出Blazor webassembly 之API服务端保护》仅用于学习参考。如对内容有疑问,请及时联系本站处理。

上篇CocoaPods的安装及使用springboot filter and interceptor实战之mdc日志打印下篇

宿迁高防,2C2G15M,22元/月;香港BGP,2C5G5M,25元/月 雨云优惠码:MjYwNzM=

相关文章

es版本2.x的string和5.x的keyword,text的区别和联系

一 es2.x和es5.x版本定义字符串类型 2.x版本的es string的类型 全文检索   分词   index=analysis  按单个字符匹配    被称作analyzed字符串 关键词搜索 不分词  index=not_analysis  按照整个文本进行匹配  被称为not-analyzed字符串 index=no  表示不被索引,产生的后...

使用Mybatis执行sql脚本

pom.xml <?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"...

java08 数组与集合

1 数组的定义与规范 一个变量只能存放一个数据,一个数组是一个容器,可以存放多个数据 数组的特点 1 数组是一种引用数据类型 2 数组中的多个数据,类型必须统一 3 数组的长度在程序运行期间,不可改变 数组的初始化 1 动态初始化  指定长度:  数据类型[] 数组名称 = new数据类型 [ 数组长度] 左侧的数据类型  表示数组中保存的数据类型 左侧...

(转)Asp.Net(C#) XML+Xslt转Excel的解决方案

1. 新建一个Excel文档,并填写表头与两行左右的内容,然后另存为XML表格 格式 并修改成Xslt模板;2. 将要导入的数据生成XML格式文档;3. 通过Xslt模板将数据生成,并设定Response.ContentType = "application/vnd.ms-excel"; 4. 刷新输出页保存文件即为Excel格式的文档 ExportCar...

Java实现 “ 将数字金额转为大写中文金额 ”

前言:输入数字金额参数,运行程序得到其对应的大写中文金额;例如:输入 12.56,输出 12.56 : 壹拾贰元伍角陆分;重点来了:本人亲测有效。 奉上代码:/*** @Title: ConvertUpMoney* @Description: 将数字金额转换为大写中文金额* @date: 2019年6月18日 下午10:52:27*/public clas...

python基础练习题(题目 递归输出)

day19 --------------------------------------------------------------- 实例027:递归输出 题目 利用递归函数调用方式,将所输入的5个字符,以相反顺序打印出来。 分析:相反顺序可以用列表来,直接pop方法。 1 def reverseprint(a): 2 lit = list(...