查看原文
其他

用JWT来保护我们的ASP.NET Core Web API

DotNet 2019-08-03

(点击上方蓝字,可快速关注我们)


来源:Catcher8

cnblogs.com/catcher1994/p/6057484.html


《Middleware给ASP.NET Core Web API添加自己的授权验证》中,自己动手写了一个Middleware来处理API的授权验证,现在就采用另外一种方式来处理这个授权验证的问题,毕竟现在也有不少开源的东西可以用,今天用的JWT。


什么是JWT呢?JWT的全称是JSON WEB TOKENS,是一种自包含令牌格式。官方网址:https://jwt.io/,或多或少应该都有听过这个。


先来看看下面的两个图:



站点是通过RPC的方式来访问api取得资源的,当站点是直接访问api,没有拿到有访问权限的令牌,那么站点是拿不到相关的数据资源的。


就像左图展示的那样,发起了请求但是拿不到想要的结果;


当站点先去授权服务器拿到了可以访问api的access_token(令牌)后,再通过这个access_token去访问api,api才会返回受保护的数据资源。


这个就是基于令牌验证的大致流程了。可以看出授权服务器占着一个很重要的地位。


下面先来看看授权服务器做了些什么并如何来实现一个简单的授权。


做了什么?授权服务器在整个过程中的作用是:


接收客户端发起申请access_token的请求,并校验其身份的合法性,最终返回一个包含access_token的json字符串。


如何实现?我们还是离不开中间件这个东西。


这次我们写了一个TokenProviderMiddleware,主要是看看invoke方法和生成access_token的方法。


/// <summary>

/// invoke the middleware

/// </summary>

/// <param name="context"></param>

/// <returns></returns>

public async Task Invoke(HttpContext context)

{           

    if (!context.Request.Path.Equals(_options.Path, StringComparison.Ordinal))

    {

        await _next(context);

    }

    // Request must be POST with Content-Type: application/x-www-form-urlencoded

    if (!context.Request.Method.Equals("POST")

       || !context.Request.HasFormContentType)

    {

        await ReturnBadRequest(context);             

    }

    await GenerateAuthorizedResult(context);

}


Invoke方法其实是不用多说的,不过我们这里是做了一个控制,只接收POST请求,并且是只接收以表单形式提交的数据,GET的请求和其他contenttype类型是属于非法的请求,会返回bad request的状态。


下面说说授权中比较重要的东西,access_token的生成。


/// <summary>

/// get the jwt

/// </summary>

/// <param name="username"></param>

/// <returns></returns>

private string GetJwt(string username)

{

    var now = DateTime.UtcNow;

    var claims = new Claim[]

    {

        new Claim(JwtRegisteredClaimNames.Sub, username),

        new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),

        new Claim(JwtRegisteredClaimNames.Iat, now.ToUniversalTime().ToString(),

                  ClaimValueTypes.Integer64)

    };

    var jwt = new JwtSecurityToken(

        issuer: _options.Issuer,

        audience: _options.Audience,

        claims: claims,

        notBefore: now,

        expires: now.Add(_options.Expiration),

        signingCredentials: _options.SigningCredentials);

    var encodedJwt = new JwtSecurityTokenHandler().WriteToken(jwt);

    var response = new

    {

        access_token = encodedJwt,

        expires_in = (int)_options.Expiration.TotalSeconds,

        token_type = "Bearer"

    };   

    return JsonConvert.SerializeObject(response, new JsonSerializerSettings { Formatting = Formatting.Indented });

}


claims包含了多个claim,你想要那几个,可以根据自己的需要来添加,JwtRegisteredClaimNames是一个结构体,里面包含了所有的可选项。


public struct JwtRegisteredClaimNames

{

    public const string Acr = "acr";

    public const string Actort = "actort";

    public const string Amr = "amr";

    public const string AtHash = "at_hash";

    public const string Aud = "aud";

    public const string AuthTime = "auth_time";

    public const string Azp = "azp";

    public const string Birthdate = "birthdate";

    public const string CHash = "c_hash";

    public const string Email = "email";

    public const string Exp = "exp";

    public const string FamilyName = "family_name";

    public const string Gender = "gender";

    public const string GivenName = "given_name";

    public const string Iat = "iat";

    public const string Iss = "iss";

    public const string Jti = "jti";

    public const string NameId = "nameid";

    public const string Nbf = "nbf";

    public const string Nonce = "nonce";

    public const string Prn = "prn";

    public const string Sid = "sid";

    public const string Sub = "sub";

    public const string Typ = "typ";

    public const string UniqueName = "unique_name";

    public const string Website = "website";

}


还需要一个JwtSecurityToken对象,这个对象是至关重要的。有了时间、Claims和JwtSecurityToken对象,只要调用JwtSecurityTokenHandler的WriteToken就可以得到类似这样的一个加密之后的字符串,这个字符串由3部分组成用‘.’分隔。每部分代表什么可以去官网查找。


eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ


最后我们要用json的形式返回这个access_token、access_token的有效时间和一些其他的信息。


还需要在Startup的Configure方法中去调用我们的中间件。


var audienceConfig = Configuration.GetSection("Audience");

var symmetricKeyAsBase64 = audienceConfig["Secret"];

var keyByteArray = Encoding.ASCII.GetBytes(symmetricKeyAsBase64);

var signingKey = new SymmetricSecurityKey(keyByteArray);

app.UseTokenProvider(new TokenProviderOptions

{

    Audience = "Catcher Wong",

    Issuer = "http://catcher1994.cnblogs.com/",

    SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256),

});


到这里,我们的授权服务站点已经是做好了。下面就编写几个单元测试来验证一下这个授权。


测试一:授权服务站点能生成正确的jwt。


[Fact]

public async Task authorized_server_should_generate_token_success()

{

    //arrange

    var data = new Dictionary<string, string>();

    data.Add("username", "Member");

    data.Add("password", "123");

    HttpContent ct = new FormUrlEncodedContent(data);


    //act

    System.Net.Http.HttpResponseMessage message_token = await _client.PostAsync("http://127.0.0.1:8000/auth/token", ct);

    string res = await message_token.Content.ReadAsStringAsync();

    var obj = Newtonsoft.Json.JsonConvert.DeserializeObject<Token>(res);


    //assert

    Assert.NotNull(obj);

    Assert.Equal("600", obj.expires_in);

    Assert.Equal(3, obj.access_token.Split('.').Length);

    Assert.Equal("Bearer", obj.token_type);

}


测试二:授权服务站点因为用户名或密码不正确导致不能生成正确的jwt。


[Fact]

public async Task authorized_server_should_generate_token_fault_by_invalid_app()

{

    //arrange

    var data = new Dictionary<string, string>();

    data.Add("username", "Member");

    data.Add("password", "123456");

    HttpContent ct = new FormUrlEncodedContent(data);


    //act

    System.Net.Http.HttpResponseMessage message_token = await _client.PostAsync("http://127.0.0.1:8000/auth/token", ct);

    var res = await message_token.Content.ReadAsStringAsync();

    dynamic obj = Newtonsoft.Json.JsonConvert.DeserializeObject(res);


    //assert

    Assert.Equal("invalid_grant", (string)obj.error);

    Assert.Equal(HttpStatusCode.BadRequest, message_token.StatusCode);

}


测试三:授权服务站点因为不是发起post请求导致不能生成正确的jwt。


[Fact]

public async Task authorized_server_should_generate_token_fault_by_invalid_httpmethod()

{

    //arrange

    Uri uri = new Uri("http://127.0.0.1:8000/auth/token?username=Member&password=123456");

    //act

    System.Net.Http.HttpResponseMessage message_token = await _client.GetAsync(uri);

    var res = await message_token.Content.ReadAsStringAsync();

    dynamic obj = Newtonsoft.Json.JsonConvert.DeserializeObject(res);

    //assert

    Assert.Equal("invalid_grant", (string)obj.error);

    Assert.Equal(HttpStatusCode.BadRequest, message_token.StatusCode);

}


再来看看测试的结果:

 


都通过了。


断点拿一个access_token去http://jwt.calebb.net/ 解密看看


eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJNZW1iZXIiLCJqdGkiOiI2MzI1MmE1My0yMjY5LTQ4YzEtYmQwNi1lOWRiMzdmMTRmYTQiLCJpYXQiOiIyMDE2LzExLzEyIDI6NDg6MTciLCJuYmYiOjE0Nzg5MTg4OTcsImV4cCI6MTQ3ODkxOTQ5NywiaXNzIjoiaHR0cDovL2NhdGNoZXIxOTk0LmNuYmxvZ3MuY29tLyIsImF1ZCI6IkNhdGNoZXIgV29uZyJ9.Cu2vTJ4JAHgbJGzwv2jCmvz17HcyOsRnTjkTIEA0EbQ



下面就是API的开发了。


这里是直接用了新建API项目生成的ValueController作为演示,毕竟跟ASP.NET Web API是大同小异的。


这里的重点是配置JwtBearerAuthentication,这里是不用我们再写一个中间件了,我们是定义好要用的Option然后直接用JwtBearerAuthentication就可以了。


public void ConfigureJwtAuth(IApplicationBuilder app)

{            

    var audienceConfig = Configuration.GetSection("Audience");

    var symmetricKeyAsBase64 = audienceConfig["Secret"];

    var keyByteArray = Encoding.ASCII.GetBytes(symmetricKeyAsBase64);

    var signingKey = new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey(keyByteArray);            

    var tokenValidationParameters = new TokenValidationParameters

    {

        // The signing key must match!

        ValidateIssuerSigningKey = true,

        IssuerSigningKey = signingKey,

        // Validate the JWT Issuer (iss) claim

        ValidateIssuer = true,

        ValidIssuer = "http://catcher1994.cnblogs.com/",

        // Validate the JWT Audience (aud) claim

        ValidateAudience = true,

        ValidAudience = "Catcher Wong",

        // Validate the token expiry

        ValidateLifetime = true,

        ClockSkew = TimeSpan.Zero

    };

    app.UseJwtBearerAuthentication(new JwtBearerOptions

    {

        AutomaticAuthenticate = true,

        AutomaticChallenge = true,

        TokenValidationParameters = tokenValidationParameters,

    });                        

}


然后在Startup的Configure中调用上面的方法即可。


public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)

{

    loggerFactory.AddConsole(Configuration.GetSection("Logging"));

    loggerFactory.AddDebug();

    ConfigureJwtAuth(app);

    app.UseMvc();

}


到这里之后,大部分的工作是已经完成了,还有最重要的一步,在想要保护的api上加上Authorize这个Attribute,这样Get这个方法就会要求有access_token才会返回结果,不然就会返回401。


这是在单个方法上的,也可以在整个控制器上面添加这个Attribute,这样控制器里面的方法就都会受到保护。


// GET api/values/5

[HttpGet("{id}")]

[Authorize]

public string Get(int id)

{

    return "value";

}


OK,同样编写几个单元测试验证一下。


测试一:valueapi在没有授权的请求会返回401状态。


[Fact]

public void value_api_should_return_unauthorized_without_auth()

{           

    //act         

    HttpResponseMessage message = _client.GetAsync("http://localhost:63324/api/values/1").Result;

    string result = message.Content.ReadAsStringAsync().Result;

 

    //assert

    Assert.False(message.IsSuccessStatusCode);

    Assert.Equal(HttpStatusCode.Unauthorized,message.StatusCode);

    Assert.Empty(result);

}


测试二:valueapi请求没有[Authorize]标记的方法时能正常返回结果。


[Fact]

public void value_api_should_return_result_without_authorize_attribute()

{

    //act         

    HttpResponseMessage message = _client.GetAsync("http://localhost:63324/api/values").Result;

    string result = message.Content.ReadAsStringAsync().Result;

    var res = Newtonsoft.Json.JsonConvert.DeserializeObject<string[]>(result);

    //assert

    Assert.True(message.IsSuccessStatusCode);

    Assert.Equal(2, res.Length);

}


测试三:valueapi在授权的请求中会返回正确的结果。


[Fact]

public void value_api_should_success_by_valid_auth()

{

    //arrange

    var data = new Dictionary<string, string>();

    data.Add("username", "Member");

    data.Add("password", "123");

    HttpContent ct = new FormUrlEncodedContent(data);

    //act

    var obj = GetAccessToken(ct);                        

    _client.DefaultRequestHeaders.Add("Authorization", "Bearer " + obj.access_token);

    HttpResponseMessage message = _client.GetAsync("http://localhost:63324/api/values/1").Result;

    string result = message.Content.ReadAsStringAsync().Result;

    //assert

    Assert.True(message.IsSuccessStatusCode);

    Assert.Equal(3, obj.access_token.Split('.').Length);

    Assert.Equal("value",result);            

}


再来看看测试的结果:



测试通过。


再通过浏览器直接访问那个受保护的方法。响应头就会提示www-authenticate:Bearer,这个是身份验证的质询,告诉客户端必须要提供相应的身份验证才能访问这个资源(api)。



这也是为什么在单元测试中会添加一个Header的原因,正常的使用也是要在请求的报文头中加上这个。


 _client.DefaultRequestHeaders.Add("Authorization", "Bearer " + obj.access_token); 


其实看一下源码,更快知道为什么。


JwtBearerHandler.cs(https://github.com/aspnet/Security/blob/22d2fe99c6fd9806b36025399a217a3a8b4e50f4/src/Microsoft.AspNetCore.Authentication.JwtBearer/JwtBearerHandler.cs)


下图是关于头部加Authorization的源码解释。



JwtBearer的源码:

Microsoft.AspNetCore.Authentication.JwtBearer(https://github.com/aspnet/Security/tree/22d2fe99c6fd9806b36025399a217a3a8b4e50f4/src/Microsoft.AspNetCore.Authentication.JwtBearer)


本文的示例代码:JWTTokenDemo(https://github.com/hwqdt/Demos/tree/master/src/JWTTokenDemo)

 

看完本文有收获?请转发分享给更多人

关注「DotNet」,提升.Net技能 

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存