源宝导读:微软跨平台技术框架—.NET Core已经日趋成熟,已经具备了支撑大型系统稳定运行的条件。本文将介绍明源云ERP平台从.NET Framework向.NET Core迁移过程中的实践经验。
一、背景
随着ERP的产品线越来越多,业务关联也日益复杂,应用间依赖关系也变得错综复杂,单体架构的弱点日趋明显。19年初,由于平台底层支持了分应用部署模式,将ERP从应用子系统层面进行了切割分离,迈出了从单体架构向微服务架构转型的坚实一步。不久的将来,ERP会进一步将各业务拆分成众多的微服务,而微服务势必需要进行容器化部署和运行管理,这就要求ERP技术底层必须支持跨平台,所以将现有ERP系统从.NET Framework迁移到 .NET Core平台势在必行。
上一篇我们讲述了Erp改造.Net Core之静态文件,这一篇我们将讲述在认证和授权改造过程中遇到的问题和解决思路。
二、关于认证和授权
权限控制是应用程序中最常见的需求,无论是在Asp.Net还是Asp.Net Core中都有相应的支持,而权限控制在框架层面支持主要是两部分,身份认证和身份授权:
- 身份认证:识别当前请求的用户信息,一般是通过加密的Cookies实现。
- 身份授权:识别当前请求是否有访问指定资源的权限,一般是根据当前请求识别的用户信息,结合角色权限相关配置来判断。
三、Asp.Net认证和授权的实现
这里不区分Asp.Net WebForm和Asp.Net MVC,因为两者都是基于HttpModule来进行身份认证和授权。
在Asp.Net中认证是通过FormsAuthenticationModule实现,授权是通过UrlAuthorizationModule实现,需要在web.config中做如下配置:
简述一下用户访问网站授权一个流程:
- 假设用户访问首页/PubPlatform/Nav/Home/Default.aspx。
- UrlAuthorizationModule 根据禁止匿名用户访问的配置,要求用户进行身份认证。
- 跳转到登陆页面/ PubPlatform/ Login/index.aspx进行身份认证。
- 服务端判断用户输入用户密码信息。
- 如果验证通过将认证信息写入Repsonse的Cookies中并跳转到/PubPlatform/Nav/Home/Default.aspx。
- UrlAuthorizationModule 根据配置发现已经有用户信息直接,开始进入到HttpHandler处理程序。
PS:整个认证过程远比这个复杂,因为本文重点不在这里,只做简单的描述,了解基本流程即可,有兴趣可以参考https://www.cnblogs.com /fish-li/archive/2012/04/15/2450571.html。
第二段配置中,通过用户所在的组来控制是否有modeling目录的访问权限,如果需要更多的权限配置可以添加更多类似的配置,实际在使用过程中很少使用这种方式,因为用户角色和页面会有很多,配置会很多而且不能实现动态配置,所以一般是在应用程序中处理,在Webform中一般采用类似如下方式:
public class BacePage:Page
{
protected override void OnLoad(EventArgs e)
{
//在这里根据HttpContext.User来获取用户信息然后获取角色权限来判断当前资源可否访问
base.OnLoad(e);
}
}
在Mvc中一般采用继承自AuthorizationFilterAttribute来处理,逻辑跟上述代码中演示的类似。
四、Asp.Net Core
在Core中引入Authentication需要在Startup.cs进行引入,一般和老版本一样使用Cookies做认证信息的承载,承载的代码如下:
public void ConfigureServices(IServiceCollection services)
{
...
services.AddAuthentication("Cookies")
.AddCookie("Cookies");
...
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
...
app.UseAuthentication();
...
}
在ConfigureServices(应用初始化过程),引入了Cookies作为默认的认证方式,在Configure(管道初始化)过程中引入认证的中间件。下面会简要分析下认证和授权的过程:
- 请求进入Authentication Middleware中间件之后首先会IAuthentication RequestHandler 接口做权限判断,这个组件主要使用来做远程认证的,例如SSO登录的场景。然后再做默认的认证:以Cookies认证为例,主要是读取Cookies的信息到HttpContext.User中。
- 经过中间件之后会进入AuthorizeFilter中(这里和认证和授权的核心地方)。
- 获取全局,Controller,Action之上的元数据计算之后得出AuthorizationPolicy。
- 通过IPolicyEvaluator调用Authentication模块进行认证信息解析。
- 通过IPolicyEvaluator携带Authentication结果调用Authentization模块进行授权信息解析。
核心代码如下:
//AuthenticationMiddleware 部分代码
public async Task Invoke(HttpContext context)
{
context.Features.Set(new AuthenticationFeature
{
OriginalPath = context.Request.Path,
OriginalPathBase = context.Request.PathBase
});
// Give any IAuthenticationRequestHandler schemes a chance to handle the request
var handlers = context.RequestServices.GetRequiredService();
foreach (var scheme in await Schemes.GetRequestHandlerSchemesAsync())
{
var handler = await handlers.GetHandlerAsync(context, scheme.Name) as IAuthenticationRequestHandler;
if (handler != null && await handler.HandleRequestAsync())
{
return;
}
}
var defaultAuthenticate = await Schemes.GetDefaultAuthenticateSchemeAsync();
if (defaultAuthenticate != null)
{
var result = await context.AuthenticateAsync(defaultAuthenticate.Name);
if (result?.Principal != null)
{
context.User = result.Principal;
}
}
await _next(context);
}
// AuthorizeFilte部分代码
public virtual async Task OnAuthorizationAsync(AuthorizationFilterContext context)
{
if(context == null)
{
throw new ArgumentNullException(nameof(context));
}
if (!context.IsEffectivePolicy(this))
{
return;
}
// IMPORTANT: Changes to authorization logic should be mirrored in security's AuthorizationMiddleware
var effectivePolicy = await GetEffectivePolicyAsync(context);
if (effectivePolicy == null)
{
return;
}
var policyEvaluator = context.HttpContext.RequestServices.GetRequiredService();
var authenticateResult = await policyEvaluator.AuthenticateAsync(effectivePolicy, context.HttpContext);
// Allow Anonymous skips all authorization
if (HasAllowAnonymous(context))
{
return;
}
var authorizeResult = await policyEvaluator.AuthorizeAsync(effectivePolicy, authenticateResult, context.HttpContext, context);
if (authorizeResult.Challenged)
{
context.Result = new ChallengeResult(effectivePolicy.AuthenticationSchemes.ToArray());
}
else if (authorizeResult.Forbidden)
{
context.Result = new ForbidResult(effectivePolicy.AuthenticationSchemes.ToArray());
}
}
如果有兴趣的话可以继续往里面跟踪一下代码发现最终起作用的是IAuthentication Handler和IAuthorizationHandler,而最终这两个Handler的引入在Core中对应两个概念:
- AuthenticationScheme 认证计划。
- AuthorizationRequirement 授权条件。
最终Core的认证和授权一般都是通过这两个来进行扩展,这里特别说明一点,在Mvc Core中将身份认证和授权进行了统一的规划。因为在认证过程中,MVC Core只是只是解析,但并不实际将Http请求进行返回处理。实际过程是在AuthorizationRequirement进行处理的,这里特别提到Mvc Core中的默认授权DenyAnonymousAuthorizationRequirement,通过这个授权条件判断是否有认证的用户信息来进行权限的判断。
资源能否访问这个理解为授权,那么身份认证只是授权的一种方式。
在Asp.Net WebFrom中使用基于HttpModule进行身份认证会丢失掉很多元数据的信息,因为最终方法的执行是在HttpHandler上,而HttpModule并不知道那个Handler也不知道是那个方法,就会大量使用HttpContext的解析,提高了复杂度。
如果在HttpModule中进行,如果有多种身份认证方式,那么无论最终资源需要何种方式都会经过很多HttpModule,走了无谓的分支。
另外Webfrom中授权一般写在基类中,基类必定会引入不同的继承分支,这样也不便于维护.对比Mvc Core中使用的特性标记+策略者的使用方式,更加利于维护和扩展,而不用对原有逻辑进行变动。
对比Asp.Net MVC中其中有IAuthenticationFilter和IAuthorizationFilter来处理授权和认证,然而在Asp.Net MVC Core中 只有IAuthorizationFilter。
对于资源的访问应该只有可否访问的判断即授权,而是否身份认证只能说是判断授权一种常用方式,在MVC Core中将认证通过DenyAnonymousAuthorizationRequirement融入授权以后,真正的将授权和认证融为一体,而非之前看似两个并行的概念,相互不同的处理逻辑。
在Mvc Core通过AOP进行接入,然后一系列的封装,最终将身份和授权通过Scheme + IAuthrenticationHandler和Requirement + AuthorizationHandler,这种类似于策略者模式的方式,对外提供扩展。更加易于扩展,而实际并不用太多考虑AOP部分的逻辑。最终授权和认证之需要我们进行Attribute标记然后MVC Core会自动加载到自己的元数据中,另外在RazorPage还可以主动调用如下方法进行授权的添加:
//对目录进行授权
RazorPageOptions.Conventions.AuthorizeFolder();
//对页面进行授权
RazorPageOptions.Convertions.AuthorizePage();
五、ERP认证和授权
ERP页面的部分使用传统webform的认证体系,而提供的接口部分使用了自定义HttpModule的方式,由于很多ERP演化升级过程中被动接受了很多身份认证方式的接入,所以原本实现授权的HttoModule的逻辑变得异常复杂,而这一次在改造Core的过程中进行了全面的改造后面会进行讲述。同样授权也是一样,将原本实现在Page基类中的复杂的判断逻辑进行了改造。
ERP在pub接口的身份认证方面需要实现对如下不同的方式:
- Pub接口
- 内部集成
- 接口管家
- Mip集成
- OA集成
之前讲过ERP是通过元数据来驱动的页面逻辑,权限实际是通过通过元数据配置到界面元素上的权限点来进行标识。通过常用的用户-角色-权限的模型模型来关联用户和权限点,然后通过在Page的基类中通过拿到用户的权限来判断是否有当前URL的访问权限,在按钮渲染的时候来获取是否有该按钮的权限点来控制是否显示。
在这次的改造过程中我们进行了如下改造:
- Mvc Core CookiesAuthentication中间件来实现页面的身份认证。
- 扩展MVC Core 的 AuthenticationScheme来实现接口的身份认证。
- 扩展MVC Core 的 Authentization Policy +Requirement来实现页面的身份授权。
下面通过这三部分进行讲述。
5.1、CookiesAuthentication身份认证
MVC Core 自带的基于Cookies的身份认证已经能满足我们的需求,只需要稍微进行一下配置即可。通过一张图我们了解下整个的身份认证流程,使用代码参考本章开头Asp.Net Core对认证和授权进行分析的部分。
因为只是使用来说已经比较简单这里不做过多的描述。这里有一个点需要注意,在Cookie的认证中如果是负载均衡的场景,两个不同进程的引用需要都可以进行客户端Cookies的解密。那么就需要共享加解密的key,这里我们使用MVC Core自带的基于Entity Framework Core 的密钥持久化方式,通过存放到数据库来实现密钥的共享。这部分内容可以参考:https://docs.microsoft.com/zh-cn/aspnet/core/security/data-protection/implementation/key-storage-providers?view=aspnetcore-3.1&tabs=visual-studio。
5.2、Pub接口的身份认证
除了页面访问以及页面ajax请求这些事基于Cookies的身份认证之外,在提供的接口用了其他的认证方式,这次将所有接口的认证方式进行了抽象,统一使用OpenApi的Scheme进行认证,而实际各种不同的认证方式我们是通过实入AuthenticationMiddleware中调用的IAuthenticationRequestHandler来实现的。首先我们看一下执行的流程:
之前有一篇关于Mvc Core的Controller改造的文章,其中将pub接口的发现和特性路由扩展进行了扩展,这里我们在扩展的地方添加OpenApi类型Scheme的元数据。
因为我们的接口统一是使用/pub/开头的所以我们选择使用扩展IAuthentication RequestHandler来实现,这样在判断是否要进行认证的时候需要判断url是否以pub开头即可,下面是类图:
其中核心就是MockUserAuthHandler,因为在Erp的接口中都会用到用户相关的信息,所以我们所有的pub接口操作实际的身份认证都是模拟了用户的认证,这里将这个概念显式的移动到基类之中,我们通过如下代码进行接入: 这里我们添加了众多的Scheme只是为了在AuthenticationMiddleware中处理请求并进行模拟用户登录,并未有实际的Action上有此Sheme,在Action上使用的只有AppConst.ApiSchemeName(“OpenApiScheme”)。
在Cookies的认证的流程中因为是在登录过程中将认证信息写到Cookies中,而其他请求只需要进行Cookies解析即可获得用户信息。而pub接口请求是在一个用户请求过程中完成整个过程,实际是通过解析Header的特定内容,然后跟保存在Erp中的认证信息进行对比来确定身份,最后在模拟用户之后,进行登录操作来完成整个身份认证的流程。
这里我们采用扩展IAuthentication RequestHandler的方式有如下考虑:
- 如果采用IAuthenticationHandler + Sheme,Core的标准是按照不同的请求使用不同Scheme无法支持,而实际来说都是通过pub接口进行区分,并没有区分到具体的请求。
- 目前ERP的认证方式跟行业标准方案不同,以后如果接入标准方案,增加Scheme更加容易扩展并兼容现有方案。
- IAuthenticationRequestHandler官方给出是远程认证时候使用,在以后前后端分离加上微服务的升级过程中,只需要修改这部分请求中认证信息发送给远程服务器认证即可,更加符合官方推荐使用的风格。
综合来说采取IAuthentication RequestHandler而不采用IAuthenticationHandler + Sheme 方式,还是基于官方给出的推荐使用风格和Erp扩展需求综合考虑而来。
5.3、Razor页面授权
在页面改造篇我们讲述了页面改造的过程,我们知道RazorPage实际是类似CodeBehind的模式,通过PageModel的OnGet方法,每个页面都有对应的PageModel,所以我们这里定义的BasePageModel基类如下:
public static AuthenticationBuilder AddOpenApiAuth(this AuthenticationBuilder builder)
{
builder.AddScheme(AppConst.ApiSchemeName, AppConst.ApiSchemeName,null);
return builder.AddScheme(AppConst.OpenApi, AppConst.OpenApi, null)
.AddScheme(AppConst.AccessToken, AppConst.AccessToken, null)
.AddScheme(AppConst.OAuthV1, AppConst.OAuthV1, null)
.AddScheme(AppConst.OAuthV2, AppConst.OAuthV2, null)
.AddScheme(AppConst.InternalIntegration, AppConst.InternalIntegration, null);
}
这里我们使用Cookies的Scheme进行授权,特别的是我们还增加了AuthentizationPolicy的定义,通过定义的Policy加上PolicyProvider来实现不同的Policy的切换,Policy的使用代码如下:
public static IMvcBuilder AddFrontend(this IMvcBuilder builder)
{
builder.AddRazorPagesOptions(options =>
{
//…
//建模的权限
options.Conventions.AuthorizeFolder(“/modeling”, AppConst.ModelingAuth);
});
//页面权限
builder.Services.AddMapAuth();
//...
}
//注册
private static void AddMapAuth(this IServiceCollection services)
{
services.AddSingleton();
services.TryAddEnumerable(ServiceDescriptor.Transient());
services.TryAddEnumerable(ServiceDescriptor.Transient());
services.TryAddEnumerable(ServiceDescriptor.Transient());
}
//Policy实现
internal class MapAuthPolicyProvider : IAuthorizationPolicyProvider
{
private DefaultAuthorizationPolicyProvider FallbackPolicyProvider { get; }
public MapAuthPolicyProvider(IOptions options)
{
FallbackPolicyProvider = new DefaultAuthorizationPolicyProvider(options);
}
public Task GetPolicyAsync(string policyName)
{
if (policyName.StartsWith(AppConst.ModelingAuth, StringComparison.OrdinalIgnoreCase))
{
var policy = new AuthorizationPolicyBuilder();
policy.AddRequirements(new ModelingRequirement());
return Task.FromResult(policy.Build());
}
if (policyName.StartsWith(AppConst.AppAuth, StringComparison.OrdinalIgnoreCase))
{
var policy = new AuthorizationPolicyBuilder();
policy.AddRequirements(new AppAuthRequirement());
return Task.FromResult(policy.Build());
}
if (policyName.StartsWith(AppConst.UserAuth, StringComparison.OrdinalIgnoreCase))
{
var policy = new AuthorizationPolicyBuilder();
policy.AddRequirements(new UserAuthRequirement());
return Task.FromResult(policy.Build());
}
//返回默认的认证
return FallbackPolicyProvider.GetPolicyAsync(policyName);
}
public Task GetDefaultPolicyAsync()
{
return FallbackPolicyProvider.GetDefaultPolicyAsync();
}
}
通过上述代码实现了不同策略使用不同的Requirement进行授权的校验,至于授权校验逻辑就是常见的用户权限判断。下面是整体的执行流程:
六、总结
Core认证和授权改造主要是要对Core的认证和授权的逻辑要清晰,这里核心就是去扒源码,由于之前对Asp.Net MVC的了解,直接就从AuthorizeFilter入手很快就可以找到切入点,然后了解到整个Core认证和授权的核心。
权限这部分的改造不仅仅只是代码的迁移,也是风格的一个改造,将原来认证和授权的大泥球的设计,通过Core提供的Scheme+Requirement的扩展进行结合的重新设计。
------ END ------
作者简介
熊同学: 研发工程师,目前负责ERP运行平台的设计与开发工作。
公众号:明源技术