欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

【.NET Core项目实战-统一认证平台】第十二章 授权篇-深入理解JWT生成及验证流程

程序员文章站 2023-01-14 07:56:42
" 【.NET Core项目实战 统一认证平台】开篇及目录索引 " 上篇文章介绍了基于 密码授权模式,从使用场景、原理分析、自定义帐户体系集成完整的介绍了密码授权模式的内容,并最后给出了三个思考问题,本篇就针对第一个思考问题详细的讲解下 是如何生成access_token的,如何验证access_t ......

【.net core项目实战-统一认证平台】开篇及目录索引

上篇文章介绍了基于ids4密码授权模式,从使用场景、原理分析、自定义帐户体系集成完整的介绍了密码授权模式的内容,并最后给出了三个思考问题,本篇就针对第一个思考问题详细的讲解下ids4是如何生成access_token的,如何验证access_token的有效性,最后我们使用.net webapi来实现一个外部接口(本来想用java来实现的,奈何没学好,就当抛砖引玉吧,有会java的朋友根据我写的案例使用java来实现一个案例)。

.netcore项目实战交流群(637326624),有兴趣的朋友可以在群里交流讨论。

一、jwt简介

  1. 什么是jwt?
    json web token (jwt)是一个开放标准(rfc 7519),它定义了一种紧凑的、自包含的方式,用于作为json对象在各方之间安全地传输信息。该信息可以被验证和信任,因为它是数字签名的。

  2. 什么时候使用jwt?

1)、认证,这是比较常见的使用场景,只要用户登录过一次系统,之后的请求都会包含签名出来的token,通过token也可以用来实现单点登录。

2)、交换信息,通过使用密钥对来安全的传送信息,可以知道发送者是谁、放置消息是否被篡改。

  1. jwt的结构是什么样的?

json web token由三部分组成,它们之间用圆点(.)连接。这三部分分别是:

  • header
  • payload
  • signature

header
header典型的由两部分组成:token的类型(“jwt”)和算法名称(比如:hmac sha256或者rsa等等)。

例如:

{
    "alg": "rs256",
    "typ": "jwt"
}

然后,用base64对这个json编码就得到jwt的第一部分

payload

jwt的第二部分是payload,它包含声明(要求)。声明是关于实体(通常是用户)和其他数据的声明。声明有三种类型: registered, public 和 private。

  • registered claims : 这里有一组预定义的声明,它们不是强制的,但是推荐。比如:iss (issuer), exp (expiration time), sub (subject), aud (audience)等。
  • public claims : 可以随意定义。
  • private claims : 用于在同意使用它们的各方之间共享信息,并且不是注册的或公开的声明。

下面是一个例子:

{
 "nbf": 1545919058,
 "exp": 1545922658,
 "iss": "http://localhost:7777",
 "aud": [
     "http://localhost:7777/resources",
     "mpc_gateway"
 ],
 "client_id": "clienta",
 "sub": "1",
 "auth_time": 1545919058,
 "idp": "local",
 "nickname": "金焰的世界",
 "email": "541869544@qq.com",
 "mobile": "13888888888",
 "scope": [
     "mpc_gateway",
     "offline_access"
 ],
 "amr": [
     "pwd"
 ]
}

对payload进行base64编码就得到jwt的第二部分

注意,不要在jwt的payload或header中放置敏感信息,除非它们是加密的。

signature

为了得到签名部分,你必须有编码过的header、编码过的payload、一个秘钥,签名算法是header中指定的                那个,然对它们签名即可。

例如:hmacsha256(base64urlencode(header) + "." + base64urlencode(payload), secret)

签名是用于验证消息在传递过程中有没有被更改,并且,对于使用私钥签名的token,它还可以验证jwt的发送方是否为它所称的发送方。

二、identityserver4是如何生成jwt的?

在了解了jwt的基本概念介绍后,我们要知道jwt是如何生成的,加密的方式是什么,我们如何使用自己的密钥进行加密。

identityserver4的加密方式?

ids4目前使用的是rs256非对称方式,使用私钥进行签名,然后客户端通过公钥进行验签。可能有的人会问,我们在生成ids4时,也没有配置证书,为什么也可以运行起来呢?这里就要讲解证书的使用,以及ids4使用证书的加密流程。

1、加载证书

ids4默认使用临时证书来进行token的生成,使用代码 .adddevelopersigningcredential(),这里会自动给生成tempkey.rsa证书文件,所以项目如果使用默认配置的根目录可以查看到此文件,实现代码如下:

public static iidentityserverbuilder adddevelopersigningcredential(this iidentityserverbuilder builder, bool persistkey = true, string filename = null)
{
    if (filename == null)
    {
        filename = path.combine(directory.getcurrentdirectory(), "tempkey.rsa");
    }

    if (file.exists(filename))
    {
        var keyfile = file.readalltext(filename);
        var tempkey = jsonconvert.deserializeobject<temporaryrsakey>(keyfile, new jsonserializersettings { contractresolver = new rsakeycontractresolver() });

        return builder.addsigningcredential(creatersasecuritykey(tempkey.parameters, tempkey.keyid));
    }
    else
    {
        var key = creatersasecuritykey();

        rsaparameters parameters;

        if (key.rsa != null)
            parameters = key.rsa.exportparameters(includeprivateparameters: true);
        else
            parameters = key.parameters;

        var tempkey = new temporaryrsakey
        {
            parameters = parameters,
            keyid = key.keyid
        };

        if (persistkey)
        {
            file.writealltext(filename, jsonconvert.serializeobject(tempkey, new jsonserializersettings { contractresolver = new rsakeycontractresolver() }));
        }

        return builder.addsigningcredential(key);
    }
}

这也就可以理解为什么没有配置证书也一样可以使用了。

注意:在生产环境我们最好使用自己配置的证书。

如果我们已经有证书了,可以使用如下代码实现,至于证书是如何生成的,网上资料很多,这里就不介绍了。

 .addsigningcredential(new x509certificate2(path.combine(basepath,"test.pfx"),"123456"));

然后注入证书相关信息,代码如下:

builder.services.addsingleton<isigningcredentialstore>(new defaultsigningcredentialsstore(credential));
            builder.services.addsingleton<ivalidationkeysstore>(new defaultvalidationkeysstore(new[] { credential.key }));

后面就可以在项目里使用证书的相关操作了,比如加密、验签等。

2、使用证书加密

上篇我介绍了密码授权模式,详细的讲解了流程,当所有信息校验通过,claim生成完成后,就开始生成token了,核心代码如下。

public virtual async task<string> createtokenasync(token token)
{
    var header = await createheaderasync(token);
    var payload = await createpayloadasync(token);
    return await createjwtasync(new jwtsecuritytoken(header, payload));
}
//使用配置的证书生成jwt头部
protected virtual async task<jwtheader> createheaderasync(token token)
{
    var credential = await keys.getsigningcredentialsasync();

    if (credential == null)
    {
        throw new invalidoperationexception("no signing credential is configured. can't create jwt token");
    }

    var header = new jwtheader(credential);

    // emit x5t claim for backwards compatibility with v4 of ms jwt library
    if (credential.key is x509securitykey x509key)
    {
        var cert = x509key.certificate;
        if (clock.utcnow.utcdatetime > cert.notafter)
        {//如果证书过期提示
            logger.logwarning("certificate {subjectname} has expired on {expiration}", cert.subject, cert.notafter.tostring(cultureinfo.invariantculture));
        }
        header["x5t"] = base64url.encode(cert.getcerthash());
    }

    return header;
}
//生成内容
public static jwtpayload createjwtpayload(this token token, isystemclock clock, ilogger logger)
{
    var payload = new jwtpayload(
        token.issuer,
        null,
        null,
        clock.utcnow.utcdatetime,
        clock.utcnow.utcdatetime.addseconds(token.lifetime));

    foreach (var aud in token.audiences)
    {
        payload.addclaim(new claim(jwtclaimtypes.audience, aud));
    }

    var amrclaims = token.claims.where(x => x.type == jwtclaimtypes.authenticationmethod);
    var scopeclaims = token.claims.where(x => x.type == jwtclaimtypes.scope);
    var jsonclaims = token.claims.where(x => x.valuetype == identityserverconstants.claimvaluetypes.json);

    var normalclaims = token.claims
        .except(amrclaims)
        .except(jsonclaims)
        .except(scopeclaims);

    payload.addclaims(normalclaims);

    // scope claims
    if (!scopeclaims.isnullorempty())
    {
        var scopevalues = scopeclaims.select(x => x.value).toarray();
        payload.add(jwtclaimtypes.scope, scopevalues);
    }

    // amr claims
    if (!amrclaims.isnullorempty())
    {
        var amrvalues = amrclaims.select(x => x.value).distinct().toarray();
        payload.add(jwtclaimtypes.authenticationmethod, amrvalues);
    }

    // deal with json types
    // calling toarray() to trigger json parsing once and so later 
    // collection identity comparisons work for the anonymous type
    try
    {
        var jsontokens = jsonclaims.select(x => new { x.type, jsonvalue = jraw.parse(x.value) }).toarray();

        var jsonobjects = jsontokens.where(x => x.jsonvalue.type == jtokentype.object).toarray();
        var jsonobjectgroups = jsonobjects.groupby(x => x.type).toarray();
        foreach (var group in jsonobjectgroups)
        {
            if (payload.containskey(group.key))
            {
                throw new exception(string.format("can't add two claims where one is a json object and the other is not a json object ({0})", group.key));
            }

            if (group.skip(1).any())
            {
                // add as array
                payload.add(group.key, group.select(x => x.jsonvalue).toarray());
            }
            else
            {
                // add just one
                payload.add(group.key, group.first().jsonvalue);
            }
        }

        var jsonarrays = jsontokens.where(x => x.jsonvalue.type == jtokentype.array).toarray();
        var jsonarraygroups = jsonarrays.groupby(x => x.type).toarray();
        foreach (var group in jsonarraygroups)
        {
            if (payload.containskey(group.key))
            {
                throw new exception(string.format("can't add two claims where one is a json array and the other is not a json array ({0})", group.key));
            }

            var newarr = new list<jtoken>();
            foreach (var arrays in group)
            {
                var arr = (jarray)arrays.jsonvalue;
                newarr.addrange(arr);
            }

            // add just one array for the group/key/claim type
            payload.add(group.key, newarr.toarray());
        }

        var unsupportedjsontokens = jsontokens.except(jsonobjects).except(jsonarrays);
        var unsupportedjsonclaimtypes = unsupportedjsontokens.select(x => x.type).distinct();
        if (unsupportedjsonclaimtypes.any())
        {
            throw new exception(string.format("unsupported json type for claim types: {0}", unsupportedjsonclaimtypes.aggregate((x, y) => x + ", " + y)));
        }

        return payload;
    }
    catch (exception ex)
    {
        logger.logcritical(ex, "error creating a json valued claim");
        throw;
    }
}
//生成最终的token
protected virtual task<string> createjwtasync(jwtsecuritytoken jwt)
{
    var handler = new jwtsecuritytokenhandler();
    return task.fromresult(handler.writetoken(jwt));
}

知道了这些原理后,我们就能清楚的知道access_token都放了那些东西,以及我们可以如何来验证生成的token

三、如何验证access_token的有效性?

知道了如何生成后,最主要的目的还是要直接我们服务端是如何来保护接口安全的,为什么服务端只要加入下代码就能够保护配置的资源呢?

services.addauthentication("bearer")
        .addidentityserverauthentication(options =>
            {
               options.authority ="http://localhost:7777";
               options.requirehttpsmetadata = false;
               options.apiname = "api1";
               options.savetoken = true;
            });
//启用授权 
app.useauthentication();

在理解这个前,我们需要了解系统做的验证流程,这里使用一张图可以很好的理解流程了。

【.NET Core项目实战-统一认证平台】第十二章 授权篇-深入理解JWT生成及验证流程
看完后是不是豁然开朗?这里就可以很好的理解/.well-known/openid-configuration/jwks原来就是证书的公钥信息,是通过访问/.well-known/openid-configuration暴露给所有的客户端使用,安全性是用过非对称加密的原理保证,私钥加密的信息,公钥只能验证,所以也不存在密钥泄漏问题。

虽然只是短短的几句代码,就做了那么多事情,这说明ids4封装的好,减少了我们很多编码工作。这是有人会问,那如果我们的项目不是.netcore的,那如何接入到网关呢?

网上有一个python例子,用 identity server 4 (jwks 端点和 rs256 算法) 来保护 python web api.

本来准备使用java来实现,好久没摸已经忘了怎么写了,留给会java的朋友实现吧,原理都是一样。

下面我就已webapi为例来开发服务端接口,然后使用ids4来保护接口内容。

新建一个webapi项目,项目名称czar.authplatform.webapi,为了让输出的结果为json,我们需要在webapiconfig增加config.formatters.remove(config.formatters.xmlformatter);代码,然后修改默认的控制器valuescontroller,修改代码如下。

[ids4auth("http://localhost:6611", "mpc_gateway")]
public ienumerable<string> get()
{
      var context = requestcontext.principal; 
      return new string[] { "webapi values" };
}

为了保护api安全,我们需要增加一个身份验证过滤器,实现代码如下。

using microsoft.identitymodel.tokens;
using newtonsoft.json;
using newtonsoft.json.linq;
using system;
using system.collections.generic;
using system.identitymodel.tokens.jwt;
using system.linq;
using system.net;
using system.net.http;
using system.threading;
using system.threading.tasks;
using system.web;
using system.web.http.controllers;
using system.web.http.filters;

namespace czar.authplatform.webapi
{
    public class ids4authattribute : authorizationfilterattribute
    {
        /// <summary>
        /// 认证服务器地址
        /// </summary>
        private string issurl = "";
        /// <summary>
        /// 保护的api名称
        /// </summary>
        private string apiname = "";

        public ids4authattribute(string issurl,string apiname)
        {
            issurl = issurl;
            apiname = apiname;
        }
        /// <summary>
        /// 重写验证方式
        /// </summary>
        /// <param name="actioncontext"></param>
        public override void onauthorization(httpactioncontext actioncontext)
        {
            try
            {
                var access_token = actioncontext.request.headers.authorization?.parameter; //获取请求的access_token
                if (string.isnullorempty(access_token))
                {//401
                    actioncontext.response = actioncontext.request.createresponse(httpstatuscode.unauthorized);
                    actioncontext.response.content = new stringcontent("{\"errcode\":401,\"errmsg\":\"未授权\"}");
                }
                else
                {//开始验证请求的token是否合法
                    //1、获取公钥
                    var httpclient = new httpclient();
                    var jwtkey= httpclient.getstringasync(issurl + "/.well-known/openid-configuration/jwks").result;
                    //可以在此处缓存jwtkey,不用每次都获取。
                    var ids4keys = jsonconvert.deserializeobject<ids4keys>(jwtkey);
                    var jwk = ids4keys.keys;
                    var parameters = new tokenvalidationparameters
                    { //可以增加自定义的验证项目
                        validissuer = issurl,
                        issuersigningkeys = jwk ,
                        validatelifetime = true,
                        validaudience = apiname
                    };
                    var handler = new jwtsecuritytokenhandler();
                    //2、使用公钥校验是否合法,如果验证失败会抛出异常
                    var id = handler.validatetoken(access_token, parameters, out var _);
                    //请求的内容保存
                    actioncontext.requestcontext.principal = id;
                }
            }
            catch(exception ex)
            {
                actioncontext.response = actioncontext.request.createresponse(httpstatuscode.unauthorized);
                actioncontext.response.content = new stringcontent("{\"errcode\":401,\"errmsg\":\"未授权\"}");
            }
        }
    }

    public class ids4keys
    {
        public jsonwebkey[] keys { get; set; }
    }
}

代码非常简洁,就实现了基于ids4的访问控制,现在我们开始使用postman来测试接口地址。

我们直接请求接口地址,返回401未授权。
【.NET Core项目实战-统一认证平台】第十二章 授权篇-深入理解JWT生成及验证流程

然后我使用ids4生成的access_token再次测试,可以得到我们预期结果。
【.NET Core项目实战-统一认证平台】第十二章 授权篇-深入理解JWT生成及验证流程

为了验证是不是任何地方签发的token都可以通过验证,我使用其他项目生成的access_token来测试,发现提示的401未授权,可以达到我们预期结果。
【.NET Core项目实战-统一认证平台】第十二章 授权篇-深入理解JWT生成及验证流程

现在就可以开心的使用我们熟悉的webapi开发我们的接口了,需要验证的地方增加类似[ids4auth("http://localhost:6611", "mpc_gateway")]代码即可。

使用其他语言实现的原理基本一致,就是公钥来验签,只要通过验证证明是允许访问的请求,由于公钥一直不变(除非认证服务器更新了证书),所以我们请求到后可以缓存到本地,这样验签时可以省去每次都获取公钥这步操作。

四、总结

本篇我们介绍了jwt的基本原理和ids4jwt实现方式,然后使用.net webapi实现了使用ids4保护接口,其他语言实现方式一样,这样我们就可以把网关部署后,后端服务使用任何语言开发,然后接入到网关即可。

有了这些知识点,感觉是不是对ids4的理解更深入了呢?jwt确实方便,但是有些特殊场景是我们希望token在有效期内通过人工配置的方式立即失效,如果按照现有ids4验证方式是没有办法做到,那该如何实现呢?我将会在下一篇来介绍如何实现强制token失效,敬请期待吧。