【.NET Core项目实战-统一认证平台】第十章 授权篇-客户端授权
【.net core项目实战-统一认证平台】开篇及目录索引
上篇文章介绍了如何使用
dapper
持久化identityserver4(以下简称ids4)
的信息,并实现了sqlserver
和mysql
两种方式存储,本篇将介绍如何使用ids4
进行客户端授权。.netcore项目实战交流群(637326624),有兴趣的朋友可以在群里交流讨论。
一、如何添加客户端授权?
在了解如何进行客户端授权时,我们需要了解详细的授权流程,在【.net core项目实战-统一认证平台】第八章 授权篇-identityserver4源码分析一篇中我大概介绍了客户端的授权方式,本篇再次回忆下客户端的授权方式,老规则,上源码。
首先查看获取token的方式,核心代码如下。
private async task<iendpointresult> processtokenrequestasync(httpcontext context) { _logger.logdebug("start token request."); // 1、验证客户端及授权信息结果 var clientresult = await _clientvalidator.validateasync(context); if (clientresult.client == null) { return error(oidcconstants.tokenerrors.invalidclient); } // 2、验证请求结果 var form = (await context.request.readformasync()).asnamevaluecollection(); _logger.logtrace("calling into token request validator: {type}", _requestvalidator.gettype().fullname); var requestresult = await _requestvalidator.validaterequestasync(form, clientresult); if (requestresult.iserror) { await _events.raiseasync(new tokenissuedfailureevent(requestresult)); return error(requestresult.error, requestresult.errordescription, requestresult.customresponse); } // 3、创建输出结果 _logger.logtrace("calling into token request response generator: {type}", _responsegenerator.gettype().fullname); var response = await _responsegenerator.processasync(requestresult); await _events.raiseasync(new tokenissuedsuccessevent(response, requestresult)); logtokens(response, requestresult); // 4、返回结果 _logger.logdebug("token request success."); return new tokenresult(response); }
我们需要详细分析下第一步客户端授权信息是如何验证的?核心代码如下。
/// <summary> ///验证客户端授权结果 /// </summary> /// <param name="context">请求上下文</param> /// <returns></returns> public async task<clientsecretvalidationresult> validateasync(httpcontext context) { _logger.logdebug("start client validation"); var fail = new clientsecretvalidationresult { iserror = true }; //通过请求上下文和配置信息获取校验方式,从这里我们可以知道客户端请求的几种方式。 var parsedsecret = await _parser.parseasync(context); if (parsedsecret == null) { await raisefailureeventasync("unknown", "no client id found"); _logger.logerror("no client identifier found"); return fail; } // 根据客户端id获取客户端相关信息。(配合持久化篇查看) var client = await _clients.findenabledclientbyidasync(parsedsecret.id); if (client == null) { await raisefailureeventasync(parsedsecret.id, "unknown client"); _logger.logerror("no client with id '{clientid}' found. aborting", parsedsecret.id); return fail; } secretvalidationresult secretvalidationresult = null; if (!client.requireclientsecret || client.isimplicitonly()) { _logger.logdebug("public client - skipping secret validation success"); } else { //校验客户端授权和请求的是否一致 secretvalidationresult = await _validator.validateasync(parsedsecret, client.clientsecrets); if (secretvalidationresult.success == false) { await raisefailureeventasync(client.clientid, "invalid client secret"); _logger.logerror("client secret validation failed for client: {clientid}.", client.clientid); return fail; } } _logger.logdebug("client validation success"); var success = new clientsecretvalidationresult { iserror = false, client = client, secret = parsedsecret, confirmation = secretvalidationresult?.confirmation }; await raisesuccesseventasync(client.clientid, parsedsecret.type); return success; }
这里几个方法可以从写的说明备注里就可以知道什么意思,但是 var parsedsecret = await _parser.parseasync(context);
这句话可能不少人有疑问,这段是做什么的?如何实现不同的授权方式?
这块就需要继续理解ids4
的实现思路,详细代码如下。
/// <summary> /// 检查上下文获取授权信息 /// </summary> /// <param name="context">the http context.</param> /// <returns></returns> public async task<parsedsecret> parseasync(httpcontext context) { // 遍历所有的客户端授权获取方式,提取当前哪一个满足需求 parsedsecret bestsecret = null; foreach (var parser in _parsers) { var parsedsecret = await parser.parseasync(context); if (parsedsecret != null) { _logger.logdebug("parser found secret: {type}", parser.gettype().name); bestsecret = parsedsecret; if (parsedsecret.type != identityserverconstants.parsedsecrettypes.nosecret) { break; } } } if (bestsecret != null) { _logger.logdebug("secret id found: {id}", bestsecret.id); return bestsecret; } _logger.logdebug("parser found no secret"); return null; }
就是从注入的默认实现里检测任何一个实现isecretparser
接口方法,通过转到实现,可以发现有postbodysecretparser、jwtbearerclientassertionsecretparser、basicauthenticationsecretparser
三种方式,然后再查看下注入方法,看那些实现被默认注入了,这样就清楚我们使用ids4
时支持哪几种客户端授权方式。
/// <summary> /// 添加默认的授权分析 /// </summary> /// <param name="builder">the builder.</param> /// <returns></returns> public static iidentityserverbuilder adddefaultsecretparsers(this iidentityserverbuilder builder) { builder.services.addtransient<isecretparser, basicauthenticationsecretparser>(); builder.services.addtransient<isecretparser, postbodysecretparser>(); return builder; }
从上面代码可以发现,默认注入了两种分析器,我们就可以通过这两个方式来做客户端的授权,下面会分别演示两种授权方式的实现。
-
basicauthenticationsecretparser
public task<parsedsecret> parseasync(httpcontext context) { _logger.logdebug("start parsing basic authentication secret"); var notfound = task.fromresult<parsedsecret>(null); var authorizationheader = context.request.headers["authorization"].firstordefault(); if (authorizationheader.ismissing()) { return notfound; } if (!authorizationheader.startswith("basic ", stringcomparison.ordinalignorecase)) { return notfound; } var parameter = authorizationheader.substring("basic ".length); string pair; try { pair = encoding.utf8.getstring( convert.frombase64string(parameter)); } catch (formatexception) { _logger.logwarning("malformed basic authentication credential."); return notfound; } catch (argumentexception) { _logger.logwarning("malformed basic authentication credential."); return notfound; } var ix = pair.indexof(':'); if (ix == -1) { _logger.logwarning("malformed basic authentication credential."); return notfound; } var clientid = pair.substring(0, ix); var secret = pair.substring(ix + 1); if (clientid.ispresent()) { if (clientid.length > _options.inputlengthrestrictions.clientid || (secret.ispresent() && secret.length > _options.inputlengthrestrictions.clientsecret)) { _logger.logwarning("client id or secret exceeds allowed length."); return notfound; } var parsedsecret = new parsedsecret { id = decode(clientid), credential = secret.ismissing() ? null : decode(secret), type = identityserverconstants.parsedsecrettypes.sharedsecret }; return task.fromresult(parsedsecret); } _logger.logdebug("no basic authentication secret found"); return notfound; }
由于代码比较简单,就不介绍了,这里直接模拟此种方式授权,打开
postman
,在headers中增加authorization的key,并设置value为basic yxbwy2xpzw50jtnbc2vjcmv0
,其中basic后为client_id:client_secret
值使用base64加密。然后请求后显示如图所示结果,奈斯,得到我们授权的结果。 -
postbodysecretparser
public async task<parsedsecret> parseasync(httpcontext context) { _logger.logdebug("start parsing for secret in post body"); if (!context.request.hasformcontenttype) { _logger.logdebug("content type is not a form"); return null; } var body = await context.request.readformasync(); if (body != null) { var id = body["client_id"].firstordefault(); var secret = body["client_secret"].firstordefault(); // client id must be present if (id.ispresent()) { if (id.length > _options.inputlengthrestrictions.clientid) { _logger.logerror("client id exceeds maximum length."); return null; } if (secret.ispresent()) { if (secret.length > _options.inputlengthrestrictions.clientsecret) { _logger.logerror("client secret exceeds maximum length."); return null; } return new parsedsecret { id = id, credential = secret, type = identityserverconstants.parsedsecrettypes.sharedsecret }; } else { // client secret is optional _logger.logdebug("client id without secret found"); return new parsedsecret { id = id, type = identityserverconstants.parsedsecrettypes.nosecret }; } } } _logger.logdebug("no secret in post body found"); return null; }
此种认证方式就是从
form_data
提取client_id
和client_secret
信息,我们使用postman
继续模拟客户端授权,测试结果如下,也可以得到我们想要的结果。
有了前面的两个授权方式,我们清楚了首先验证客户端的授权信息是否一致,再继续观察后续的执行流程,这时会发现tokenrequestvalidator
中列出了客户端授权的其他信息验证,详细定义代码如下。
switch (granttype) { case oidcconstants.granttypes.authorizationcode: return await runvalidationasync(validateauthorizationcoderequestasync, parameters); //客户端授权 case oidcconstants.granttypes.clientcredentials: return await runvalidationasync(validateclientcredentialsrequestasync, parameters); case oidcconstants.granttypes.password: return await runvalidationasync(validateresourceownercredentialrequestasync, parameters); case oidcconstants.granttypes.refreshtoken: return await runvalidationasync(validaterefreshtokenrequestasync, parameters); default: return await runvalidationasync(validateextensiongrantrequestasync, parameters); }
详细的授权验证代码如下,校验客户端授权的一般规则。
private async task<tokenrequestvalidationresult> validateclientcredentialsrequestasync(namevaluecollection parameters) { _logger.logdebug("start client credentials token request validation"); ///////////////////////////////////////////// // 校验客户端id是否开启了客户端授权 ///////////////////////////////////////////// if (!_validatedrequest.client.allowedgranttypes.tolist().contains(granttype.clientcredentials)) { logerror("{clientid} not authorized for client credentials flow, check the allowedgranttypes of the client", _validatedrequest.client.clientid); return invalid(oidcconstants.tokenerrors.unauthorizedclient); } ///////////////////////////////////////////// // 校验客户端是否有请求的scopes权限 ///////////////////////////////////////////// if (!await validaterequestedscopesasync(parameters, ignoreimplicitidentityscopes: true, ignoreimplicitofflineaccess: true)) { return invalid(oidcconstants.tokenerrors.invalidscope); } if (_validatedrequest.validatedscopes.containsopenidscopes) { logerror("{clientid} cannot request openid scopes in client credentials flow", _validatedrequest.client.clientid); return invalid(oidcconstants.tokenerrors.invalidscope); } if (_validatedrequest.validatedscopes.containsofflineaccessscope) { logerror("{clientid} cannot request a refresh token in client credentials flow", _validatedrequest.client.clientid); return invalid(oidcconstants.tokenerrors.invalidscope); } _logger.logdebug("{clientid} credentials token request validation success", _validatedrequest.client.clientid); return valid(); }
最终输出详细的校验结果数据,现在整个客户端授权的完整逻辑已经介绍完毕,那如何添加我们的自定义客户端授权呢?比如我要给客户端a开放一个访问接口访问权限,下面就开通客户端a为案例讲解。
开通客户端授权
根据前面介绍的验证流程,我们清楚首先需要增加客户端信息,这里起名叫clienta
,密码设置成secreta
。上一篇我们介绍了dapper
持久化identityserver4
的授权信息,所以这里我就直接以sql语句的方式来演示添加配置信息。详细的语句如下:
/* 添加客户端脚本 */ --1、添加客户端信息 insert into clients(accesstokenlifetime,clientid,clientname,enabled) values(3600,'clienta','测试客户端a',1); --2、添加客户端密钥,密码为(secreta) sha256 insert into clientsecrets values(21,'',null,'sharedsecret','2tytaaysa0zadunthsfldjeetzsyww8wzbzm8pftgni='); --3、增加客户端授权权限 insert into clientgranttypes values(21,'client_credentials'); --4、增加客户端能够访问scope insert into clientscopes values(21,'mpc_gateway');
然后我们来测试下新开通的客户端授权,如下图所示,可以正常获取授权信息了,另外一种basic授权方式可自行测试。
二、如何配合网关认证和授权?
前面使用的是项目自己进行验证的,正式项目运行时,我们会把请求放到网关中,统一由网关进行认证和授权等操作,内部api无需再次进行认证和授权,那如何实现网关认证和授权呢?
我们可以回忆下之前介绍网关篇时认证篇章,里面介绍的非常清楚。这里我们参照刚才添加的客户端a为案例增加网关授权,因为我们对外暴露的是网关地址,而不是内部具体认证项目地址。
1、添加网关授权路由
本项目的网关端口为7777,所以网关授权的地址为http://localhost:7777/connect/token
,由于为添加网关路由,直接访问报401,我们首先增加网关的路由信息。
-- 1、插入认证路由(使用默认分类) insert into ahphreroute values(1,'/connect/token','[ "post" ]','','http','/connect/token','[{"host": "localhost","port": 6611 }]', '','','','','','','',0,1); --2、加入全局配置 insert into ahphconfigreroutes values(1,3) --3、增加认证配置地址路由 insert into ahphreroute values(1,'/.well-known/openid-configuration','[ "get" ]','','http','/.well-known/openid-configuration','[{"host": "localhost","port": 6611 }]', '','','','','','','',0,1); --4、加入全局配置 insert into ahphconfigreroutes values(1,4); --5、增加认证配置地址路由 insert into ahphreroute values(1,'/.well-known/openid-configuration/jwks','[ "get" ]','','http','/.well-known/openid-configuration/jwks','[{"host": "localhost","port": 6611 }]', '','','','','','','',0,1); --6、加入全局配置 insert into ahphconfigreroutes values(1,5);
通过postman
测试,可以得到我们预期的授权信息结果。
然后继续访问我们之前配置的授权路由,提示401未授权,这块就涉及到前面网关篇的知识了,因为我们的网关增加了授权,所以需要增加客户端授权才能访问。
2、添加客户端授权访问
还记得是如何添加客户端授权的吗?详细介绍参考[【.net core项目实战-统一认证平台】第六章 网关篇-自定义客户端授权 ,我直接把授权的脚本编写如下:
--7、插入把客户端加入测试路由组2 insert into ahphclientgroup values(21,2)
使用我们刚授权的信息,再次访问之前配置的需要认证的路由,可以得到我们预期的结果,奈斯,和网关篇的内容完全一致。
注意:在配置完信息后需要清理缓存,因为我们之前做网关时,很多配置信息的读取使用了缓存。
三、如何统一输出结果?
作为一块准备应用到生产环境的产品,可能为各种第三方提供应用支持,那么统一的输出结果是必须要实现的,比如我们使用微信sdk或其他第三方sdk时,会发现它们都会列出出现错误的统一提示,由标识代码和说明组成,这里我们就需要解决如何标准化输出问题,自己业务系统输出标准结果很容易,因为都是自己控制的结果输出,那么我们网关集成ocelot
、认证集成identityserver4
,这两块如何进行标准化输出呢?
那开始我们的改造之旅吧,首先我们要明确如果遇到错误如何进行输出,我们定义一个输出基类baseresult
,详细的定义如下:
/// <summary> /// 金焰的世界 /// 2018-12-10 /// 信息输出基类 /// </summary> public class baseresult { public baseresult(int _errcode,string _errmsg) { errcode = _errcode; errmsg = _errmsg; } public baseresult() { } /// <summary> /// 错误类型标识 /// </summary> public int errcode { get; set; } /// <summary> /// 错误类型说明 /// </summary> public string errmsg { get; set; } } /// <summary> /// 金焰的世界 /// 2018-12-10 /// 默认成功结果 /// </summary> public class successresult : baseresult { public successresult() : base(0, "成功") { } }
1、网关默认输出改造
网关这段需要改造错误提示的代码和内容以及异常的输出结果,首先改造错误情况的输出结果,使用baseresult
统一输出,这里就需要重写输出中间件respondermiddleware
,下面就开始重写之旅吧。
新增自定义输出中间件czarrespondermiddleware
,详细代码如下:
using czar.gateway.configuration; using microsoft.aspnetcore.http; using ocelot.errors; using ocelot.logging; using ocelot.middleware; using ocelot.responder; using system.collections.generic; using system.net; using system.threading.tasks; namespace czar.gateway.responder.middleware { /// <summary> /// 金焰的世界 /// 2018-12-10 /// 统一输出中间件 /// </summary> public class czarrespondermiddleware: ocelotmiddleware { private readonly ocelotrequestdelegate _next; private readonly ihttpresponder _responder; private readonly ierrorstohttpstatuscodemapper _codemapper; public czarrespondermiddleware(ocelotrequestdelegate next, ihttpresponder responder, iocelotloggerfactory loggerfactory, ierrorstohttpstatuscodemapper codemapper ) : base(loggerfactory.createlogger<czarrespondermiddleware>()) { _next = next; _responder = responder; _codemapper = codemapper; } public async task invoke(downstreamcontext context) { await _next.invoke(context); if (context.iserror) {//自定义输出结果 var errmsg = context.errors[0].message; int httpstatus = _codemapper.map(context.errors); var errresult = new baseresult() { errcode = httpstatus, errmsg = errmsg }; var message = errresult.tojson(); context.httpcontext.response.statuscode = (int)httpstatuscode.ok; await context.httpcontext.response.writeasync(message); return; } else if (context.httpcontext.response.statuscode != (int)httpstatuscode.ok) {//标记失败,不做任何处理,自定义扩展时预留 } else if (context.downstreamresponse == null) {//添加如果管道强制终止,不做任何处理,修复未将对象实例化错误 } else {//继续请求下游地址返回 logger.logdebug("no pipeline errors, setting and returning completed response"); await _responder.setresponseonhttpcontext(context.httpcontext, context.downstreamresponse); } } private void seterrorresponse(httpcontext context, list<error> errors) { var statuscode = _codemapper.map(errors); _responder.seterrorresponseoncontext(context, statuscode); } } }
然后添加中间件扩展,代码如下。
using ocelot.middleware.pipeline; namespace czar.gateway.responder.middleware { /// <summary> /// 金焰的世界 /// 2018-12-10 /// 应用输出中间件扩展 /// </summary> public static class czarrespondermiddlewareextensions { public static iocelotpipelinebuilder useczarrespondermiddleware(this iocelotpipelinebuilder builder) { return builder.usemiddleware<czarrespondermiddleware>(); } } }
最后使用此扩展来接管默认的输出中间件,详细代码如下。
//builder.userespondermiddleware(); builder.useczarrespondermiddleware();
好了,网关统一输出中间件就完成了,是不是很简单呢?我们来测试下效果吧,postman
闪亮登场,
奈斯,这才是我们需要的结果,那如何异常会输出什么呢??我们来模拟下结果,我直接在服务端抛出异常测试。
默认情况会支持输出异常的堆栈信息。那如何捕获服务端异常信息呢?我们需要了解在哪里发送了后端请求,通过源码分析,发现是由httprequestermiddleware
中间件做后端请求,这时我们只需要改造下此中间件即可完成统一异常捕获。改造核心代码如下:
public async task invoke(downstreamcontext context) { var response = await _requester.getresponse(context); if (response.iserror) { logger.logdebug("ihttprequester returned an error, setting pipeline error"); setpipelineerror(context, response.errors); return; } else if(response.data.statuscode != system.net.httpstatuscode.ok) {//如果后端未处理异常,设置异常信息,统一输出,防止暴露敏感信息 var error = new internalservererror($"请求服务异常"); logger.logwarning($"路由地址 {context.httpcontext.request.path} 请求服务异常. {error}"); setpipelineerror(context, error); return; } logger.logdebug("setting http response message"); context.downstreamresponse = new downstreamresponse(response.data); }
修改测试后端服务代码如下,
// get api/values/5 [httpget("{id}")] public actionresult<string> get(int id) { throw new exception("测试异常"); }
然后通过网关访问路由地址http://localhost:7777/ctr/values/1
,输出为{"errcode":500,"errmsg":"请求服务异常"}
,得到了预期的所有目标,网关统一输出全部改造完毕。
2、认证的统一输出改造
这里为了统一风格,我们先查看下ids4
的错误提示方式和输出结果,然后配合源码可以发现到输出都是继承iendpointresult
接口,并定义了各种方式的输出,且校验失败时,输出的状态码都不是200,那么我们可以从这里下手,在网关层增加独立的判断,来兼容自定义的输出。改造代码如下:
using czar.gateway.errors; using newtonsoft.json.linq; using ocelot.logging; using ocelot.middleware; using ocelot.requester; using system.threading.tasks; namespace czar.gateway.requester.middleware { /// <summary> /// 金焰的世界 /// 2018-12-10 /// 自定义请求中间件 /// </summary> public class czarhttprequestermiddleware : ocelotmiddleware { private readonly ocelotrequestdelegate _next; private readonly ihttprequester _requester; public czarhttprequestermiddleware(ocelotrequestdelegate next, iocelotloggerfactory loggerfactory, ihttprequester requester) : base(loggerfactory.createlogger<czarhttprequestermiddleware>()) { _next = next; _requester = requester; } public async task invoke(downstreamcontext context) { var response = await _requester.getresponse(context); if (response.iserror) { logger.logdebug("ihttprequester returned an error, setting pipeline error"); setpipelineerror(context, response.errors); return; } else if(response.data.statuscode != system.net.httpstatuscode.ok) {//如果后端未处理异常,设置异常信息,统一输出,防止暴露敏感信息 if (response.data.statuscode == system.net.httpstatuscode.badrequest) {//提取ids4相关的异常(400) var result = await response.data.content.readasstringasync(); jobject jobj = jobject.parse(result); var errormsg = jobj["error"]?.tostring(); var error = new identityserver4error(errormsg??"未知异常"); setpipelineerror(context, error); return; } else { var error = new internalservererror($"请求服务异常"); logger.logwarning($"路由地址 {context.httpcontext.request.path} 请求服务异常. {error}"); setpipelineerror(context, error); return; } } logger.logdebug("setting http response message"); context.downstreamresponse = new downstreamresponse(response.data); } } }
改造完成后,我们随时请求认证记录,最终显示效果如下。
奈斯,输出风格统一啦,这样就完美的解决了两个组件的输出问题,终于可以开心的使用啦。
四、内容总结
本篇我们详细的介绍了客户端授权的原理和支持的两个授权的方式,并各自演示了调用方式,然后知道了如何在数据库端新开通一个客户端的信息,然后介绍了配合网关实现客户端的授权和认证,并再次介绍了网关端的路由配置情况,最后介绍了如何把网关和认证统一输出格式,便于我们在正式环境的使用,涉及内容比较多,如果中间实现的有不对的地方,也欢迎大家批评指正。
推荐阅读
-
【.NET Core项目实战-统一认证平台】第二章网关篇-重构Ocelot来满足需求
-
【.NET Core项目实战-统一认证平台】第十六章 网关篇-Ocelot集成RPC服务
-
【.NET Core项目实战-统一认证平台】第九章 授权篇-使用Dapper持久化IdentityServer4
-
【.NET Core项目实战-统一认证平台】第十二章 授权篇-深入理解JWT生成及验证流程
-
【.NET Core项目实战-统一认证平台】第一章 功能及架构分析
-
【.NET Core项目实战-统一认证平台】第三章 网关篇-数据库存储配置(1)
-
【.NET Core项目实战-统一认证平台】第六章 网关篇-自定义客户端授权
-
【.NET Core项目实战-统一认证平台】第十三章 授权篇-如何强制有效令牌过期
-
【.NET Core项目实战-统一认证平台】第十四章 授权篇-自定义授权方式
-
Asp.Net Core 2.0 项目实战(10) 基于cookie登录授权认证并实现前台会员、后台管理员同时登录