【.NET Core项目实战-统一认证平台】第十章 授权篇-客户端授权
原文?【.NET Core项目实战-统一认证平台】第十章 授权篇-客户端授权 【.NET Core项目实战-统一认证平台】开篇及目录索引
一、如何添加客户端授权?在了解如何进行客户端授权时,我们需要了解详细的授权流程,在【.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; }
这里几个方法可以从写的说明备注里就可以知道什么意思,但是 这块就需要继续理解 /// <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; }
就是从注入的默认实现里检测任何一个实现 /// <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; }
从上面代码可以发现,默认注入了两种分析器,我们就可以通过这两个方式来做客户端的授权,下面会分别演示两种授权方式的实现。
有了前面的两个授权方式,我们清楚了首先验证客户端的授权信息是否一致,再继续观察后续的执行流程,这时会发现 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为案例讲解。 开通客户端授权根据前面介绍的验证流程,我们清楚首先需要增加客户端信息,这里起名叫 /* 添加客户端脚本 */ --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,所以网关授权的地址为 -- 1、插入认证路由(使用默认分类) insert into AhphReRoute values(1,‘/connect/token‘,‘[ "POST" ]‘,‘http‘,‘[{"Host": "localhost","Port": 6611 }]‘,0,1); --2、加入全局配置 INSERT INTO AhphConfigReRoutes VALUES(1,3) --3、增加认证配置地址路由 insert into AhphReRoute values(1,‘/.well-known/openid-configuration‘,‘[ "GET" ]‘,1); --4、加入全局配置 INSERT INTO AhphConfigReRoutes VALUES(1,4); --5、增加认证配置地址路由 insert into AhphReRoute values(1,‘/.well-known/openid-configuration/jwks‘,1); --6、加入全局配置 INSERT INTO AhphConfigReRoutes VALUES(1,5);
通过 然后继续访问我们之前配置的授权路由,提示401未授权,这块就涉及到前面网关篇的知识了,因为我们的网关增加了授权,所以需要增加客户端授权才能访问。 2、添加客户端授权访问还记得是如何添加客户端授权的吗?详细介绍参考[【.NET Core项目实战-统一认证平台】第六章 网关篇-自定义客户端授权 ,我直接把授权的脚本编写如下: --7、插入把客户端加入测试路由组2 INSERT INTO AhphClientGroup VALUES(21,2)
使用我们刚授权的信息,再次访问之前配置的需要认证的路由,可以得到我们预期的结果,奈斯,和网关篇的内容完全一致。 注意:在配置完信息后需要清理缓存,因为我们之前做网关时,很多配置信息的读取使用了缓存。 三、如何统一输出结果?作为一块准备应用到生产环境的产品,可能为各种第三方提供应用支持,那么统一的输出结果是必须要实现的,比如我们使用微信sdk或其他第三方sdk时,会发现它们都会列出出现错误的统一提示,由标识代码和说明组成,这里我们就需要解决如何标准化输出问题,自己业务系统输出标准结果很容易,因为都是自己控制的结果输出,那么我们网关集成 那开始我们的改造之旅吧,首先我们要明确如果遇到错误如何进行输出,我们定义一个输出基类 /// <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、网关默认输出改造网关这段需要改造错误提示的代码和内容以及异常的输出结果,首先改造错误情况的输出结果,使用 新增自定义输出中间件 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();
好了,网关统一输出中间件就完成了,是不是很简单呢?我们来测试下效果吧, 奈斯,这才是我们需要的结果,那如何异常会输出什么呢??我们来模拟下结果,我直接在服务端抛出异常测试。 默认情况会支持输出异常的堆栈信息。那如何捕获服务端异常信息呢?我们需要了解在哪里发送了后端请求,通过源码分析,发现是由 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("测试异常"); }
然后通过网关访问路由地址 2、认证的统一输出改造这里为了统一风格,我们先查看下 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,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,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); } } }
改造完成后,我们随时请求认证记录,最终显示效果如下。 奈斯,输出风格统一啦,这样就完美的解决了两个组件的输出问题,终于可以开心的使用啦。 四、内容总结本篇我们详细的介绍了客户端授权的原理和支持的两个授权的方式,并各自演示了调用方式,然后知道了如何在数据库端新开通一个客户端的信息,然后介绍了配合网关实现客户端的授权和认证,并再次介绍了网关端的路由配置情况,最后介绍了如何把网关和认证统一输出格式,便于我们在正式环境的使用,涉及内容比较多,如果中间实现的有不对的地方,也欢迎大家批评指正。 (编辑:李大同) 【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容! |