zoukankan      html  css  js  c++  java
  • 认识ASP.NET MVC的5种AuthorizationFilter

    在总体介绍了筛选器及其提供机制(《深入探讨ASP.NET MVC的筛选器》)之后,我们按照执行的先后顺序对四种不同的筛选器进行单独介绍,首先来介绍最先执行的AuthorizationFilter。从命名来看,AuthorizationFilter用于完成授权相关的工作,所以它应该在Action方法被调用之前执行才能起到授权的作用。不仅限于授权,如果我们希望目标Action方法被调用之前中断执行的流程“做点什么”,都可以以AuthorizationFilter的形式来实现。[本文已经同步到《How ASP.NET MVC Works?》中]

    目录 
    一、IAuthorizationFilter 
    二、AuthorizeAttribute 
    三、RequireHttpsAttribute 
    四、ValidateInputAttribute 
    五、ValidateAntiForgeryTokenAttribute 
    六、ChildActionOnlyAttribute

    一、IAuthorizationFilter

    所有的AuthorizationFilter实现了接口IAuthorizationFilter。如下面的代码片断所示,IAuthorizationFilter定义了一个OnAuthorization方法用于实现授权的操作。作为该方法的参数filterContext是一个表示授权上下文的AuthorizationContext对象, 而AuthorizationContext直接继承自ControllerContext。

       1: public interface IAuthorizationFilter
       2: {    
       3:     void OnAuthorization(AuthorizationContext filterContext);
       4: }
       5:  
       6: public class AuthorizationContext : ControllerContext
       7: {   
       8:     public AuthorizationContext();   
       9:     public AuthorizationContext(ControllerContext controllerContext, ActionDescriptor actionDescriptor);
      10:     
      11:     public virtual ActionDescriptor ActionDescriptor { get; set; }
      12:     public ActionResult Result { get; set; }
      13: }

    AuthorizationContext的ActionDescriptor属性表示描述当前执行Action的ActionDescriptor对象,而Result属性返回一个用于在授权阶段呈现的ActionResult。AuthorizationFilter的执行是ActionInvoker进行Action执行的第一项工作,因为后续的工作(Model绑定、Model验证、Action方法执行等)只有在成功授权的基础上才会有意义

    ActionInvoker在通过执行AuthorizationFilter之前,会先根据当前的Controller上下文和解析出来的用于描述当前Action的ActionDescriptor,并以此创建一个表示授权上下文的AuthorizationContext对象。然后将此AuthorizationContext对象作为参数,按照Filter对象Order和Scope属性决定的顺序执行所有AuthorizationFilter的OnAuthorization。

    在所有的AuthorizationFilter都执行完毕之后,如果指定的AuthorizationContext对象的Result属性表示得ActionResult不为Null,整个Action的执行将会终止,而ActionInvoker将会直接执行该ActionResult。一般来说,某个AuthorizationFilter在对当前请求实施授权的时候,如果授权失败它可以通过设置传入的AuthorizationContext对象的Result属性响应一个“401,Unauthrized”回复,或者呈现一个错误页面。

    二、AuthorizeAttribute

    如果我们要求某个Action只能被认证的用户访问,可以在Controller类型或者Action方法上应用具有如下定义的AuthorizeAttribute特性。AuthorizeAttribute还可以具体限制目标Action可被访问的用户或者角色,它的Users和Roles属性用于指定被授权的用户名和角色列表,中间用采用逗号作为分隔符。如果没有显式地对Users和Roles属性进行设置,AuthorizeAttribute在进行授权操作的时候只要求访问者是被认证的用户。

       1: [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited=true, AllowMultiple=true)]
       2: public class AuthorizeAttribute : FilterAttribute, IAuthorizationFilter
       3: {
       4:     //其他成员   
       5:     public virtual void OnAuthorization(AuthorizationContext filterContext);
       6:     protected virtual HttpValidationStatus OnCacheAuthorization(HttpContextBase httpContext);
       7:   
       8:     public string Roles { get; set; }
       9:     public override object TypeId { get; }
      10:     public string Users { get; set; }
      11: }

    如果授权失败(当前访问者是未被授权用户,或者当前用户的用户名或者角色没有在指定的授权用户或者角色列表中),AuthorizeAttribute会创建一个HttpUnauthorizedResult对象,并赋值给AuthorizationContext的Result属性,意味着会响应一个状态为“401,Unauthorized”的回复。如果采用Forms认证,配置的登录页面会自动被显示。

    很多会将AuthorizeAttribute对方法的授权与PrincipalPermissionAttribute等同起来,实际上不但它们实现授权的机制不一样(后者是通过代码访问安全检验实现对方法调用的授权),它们的授权策略也一样。以下面定义的两个方法为例,应用了PrincipalPermissionAttribute的FooOrAdmin意味着可以被帐号为Foo或者具有Admin角色的用户访问,而应用了AuthorizeAttribute特性的方法FooAndAdmin方法则只能被用户Foo访问,而且该用户必须具有Admin角色。也就是说PrincipalPermissionAttribute特性对User和Role的授权逻辑是“逻辑或”,而AuthorizeAttribute 采用的则是“逻辑与”。

       1: [PrincipalPermission( SecurityAction.Demand,Name="Foo", Role="Admin")]
       2: public void FooOrAdmin()
       3: { }
       4:  
       5: [Authorize(Users="Foo", Roles="Admin")]
       6: public void FooAndAdmin()
       7: { }

    除此之外,我们可以将多个PrincipalPermissionAttribute和AuthorizeAttribute应用到同一个类型或者方法上。对于前者,如果当前用于通过了任意一个PrincipalPermissionAttribute特性的授权就有权调用目标方法;对于后者来说,意味着需要通过所有AuthorizeAttribute特性的授权在具有了调用目标方法的权限。以如下两个方法为例,用户Foo或者Bar可以有权限调用FooOrBar方法,但是没有任何一个用户有权调用CannotCall方法(因为一个用户只一个用户名)。

       1: [PrincipalPermission( SecurityAction.Demand, Name="Foo")
       2: [PrincipalPermission( SecurityAction.Demand, Name="Bar")]
       3: public void FooOrBar()
       4: { }
       5:  
       6: [Authorize(Users="Foo")]
       7: [Authorize(Users="Bar")]
       8: public void CannotCall()
       9: {}

    三、RequireHttpsAttribute

    从名称也可以看出来来,RequireHttpsAttribute这个AuthorizationFilter要求用用户总是以HTTP请求的方式访问目标方法。如果当前并不是一个HTTPS请求(通过当前HttpRequest的IsSecureConnection属性判断),在HTTP方法为GET的情下,会创建一个RedirectResult对象并用其对AuthorizationContext的Result属性进行设置,当前请求的URL地址的Scheme替换成HTTPS就成了该RedirectResult的地址。也就是说,如果当前请求地址为http://www.artech.com/home/index,会自动重定向到https://www.artech.com/home/index

       1: [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited=true, AllowMultiple=false)]
       2: public class RequireHttpsAttribute : FilterAttribute, IAuthorizationFilter
       3: {   
       4:     protected virtual void HandleNonHttpsRequest(AuthorizationContext filterContext);
       5:     public virtual void OnAuthorization(AuthorizationContext filterContext);
       6: }

    如果当前请求的HTTP方法并不是GET,RequireHttpsAttribute会直接抛出一个InvalidOperationException异常。如上面的代码片断所示,针对非HTTPS请求的处理通过调用受保护的方法HandleNonHttpsRequest来完成,如果我们需要不同的处理,可以继承RequireHttpsAttribute并重写该方法。

    四、ValidateInputAttribute

    为了避免用户在请求中包含一些不合法的内容对网站进行恶意攻击(比如XSS攻击),我们一般需要对请求的输入进行验证。如下面的代码片断所示,表示HTTP请求的基类HttpRequestBase具有一个ValidateInput方法用于验证请求的输入。实际上这个方法仅仅是在请求上作一下标记而已,在读取相应的请求输入时才根据这些表示决定是否需要进行相应的验证。不过为了便于表达,我们就将针对该ValidateInput方法的调用说成是针对请求输入的验证。

       1: public abstract class HttpRequestBase
       2: {
       3:     //其他成员
       4:     public virtual void ValidateInput();
       5: }

    所有Controller的基类ControllerBase具有如下一个布尔类型的属性ValidateRequest表示是否需要对请求输入进行验证,在默认情况下该属性的默认值为True,意味着针对请求输入的验证默认情况下是开启的。 当ActionInvoker在完成了对所有AuthorizationFilter的执行之后,会根据该属性决定是否会通过调用表示当前请求的HttpRequest对象的ValidateInput方法进行请求输入的验证。

       1: public abstract class ControllerBase : IController
       2: {
       3:     //其他成员
       4:     public bool ValidateRequest { get; set; }
       5: }

    也正是由于ActionInvoker针对请求输入验证是在完成了所有AuthorizationFilter的执行之后进行的,所以我们可以通过自定义AuthorizationFilter的方式来设置当前Controller的ValidateRequest属性进而开启或者关闭针对请求输入的验证。ValidateInputAttribute就是这么做的,我们可以从如下表示ValidateInputAttribute的定义看出来(构造函数的参数enableValidation表示是否启动针对请求的输入验证)。

       1: [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited=true, AllowMultiple=false)]
       2: public class ValidateInputAttribute : FilterAttribute, IAuthorizationFilter
       3: {   
       4:     public ValidateInputAttribute(bool enableValidation)
       5:     {
       6:         this.EnableValidation = enableValidation;
       7:     }
       8:  
       9:     public virtual void OnAuthorization(AuthorizationContext filterContext)
      10:     {
      11:         if (filterContext == null)
      12:         {
      13:             throw new ArgumentNullException("filterContext");
      14:         }
      15:         filterContext.Controller.ValidateRequest = this.EnableValidation;
      16:     }
      17:  
      18:     public bool EnableValidation { get; private set; }
      19: }

    为了让读者对ValidateInputAttribute这个AuthorizationFilter针对开启和关闭输入验证的作用有一个深刻映像,我们来进行一个简单的实例演示。在通过Visual Studio的ASP.NET MVC项目模板创建的空Web应用中我们 定义了如下一个HomeController,包含在该Controller中的两个Action方法(Action1和Action2)具有一个字符串类型的参数foo,其中Action1上应用了ValidateInputAttribute特性并将参数设置为False。

       1: public class HomeController : Controller
       2: {
       3:     [ValidateInput(false)]
       4:     public void Action1(string foo, string bar)
       5:     { 
       6:         Response.Write(string.Format("{0}: {1}<br/>", "foo", Server.HtmlEncode(foo)));
       7:         Response.Write(string.Format("{0}: {1}<br/>", "bar", Server.HtmlEncode(bar)));
       8:     }
       9:  
      10:     public void Action2(string foo, string bar)
      11:     { 
      12:         Response.Write(string.Format("{0}: {1}<br/>", "foo", Server.HtmlEncode(foo)));
      13:         Response.Write(string.Format("{0}: {1}<br/>", "bar", Server.HtmlEncode(bar)));
      14:     }
      15: }

    我们直接运行该程序并在浏览器中通过输入相应的地址来访问这两个Action,并以查询字符串的形式指定它们的两个参数。为了检验ASP.NET MVC对请求输入的验证,我们将表示参数foo的查询字符串的值设置为为“<script></script>”。如下图所示,Action1能够正常地被调用,而Action2在调用过程中抛出异常 ,并提示请求中包含危险的查询字符串。

    image

    在《ASP.NET MVC Model元数据及其定制:一个重要的接口IMetadataAware》中我们谈到可以通过AllowHtmlAttribute特性来定义表示Model元数据的ModelMetadata的RequestValidationEnabled属性的设置从而忽略对相应属性数据的验证,使之可以包含具有HTML标签的数据。这与ValidateInputAttribute的作用类似,不同的是AllowHtmlAttribute仅仅针对Model对象的默认属性,而ValidateInputAttribute则是针对整个请求。

    五、ValidateAntiForgeryTokenAttribute

    具有如下定义的System.Web.Mvc.ValidateAntiForgeryTokenAttribute用于解决一种叫做“跨站请求伪造(CSRF:Cross-Site Request Forgery)”。这是一种不同于XSS(Cross Site Script)的跨站网络攻击,如果说XSS是利用了用户对网站的信任,而CSRF就是利用了站点对认证用户的信任。

       1: [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple=false, Inherited=true)]
       2: public sealed class ValidateAntiForgeryTokenAttribute : FilterAttribute, IAuthorizationFilter
       3: {    
       4:     public ValidateAntiForgeryTokenAttribute();
       5:     public void OnAuthorization(AuthorizationContext filterContext);
       6:     public string Salt { get; set; }
       7: }

    我们通过一个简单的例子来对CSRF的原理进行说明。假设我们通过ASP.NET MVC构建了一个博客应用,作为博主的用户可以发表博文,而一般用于可以对博文发表评论。除此之外,注册用于可以修改自己的Email地址,相关的操作定义在如下所示的BlogController的Action方法UpdateAddress中。

       1: public class BlogController: Controller
       2: {        
       3:     [Authorize]
       4:     [HttpPost]
       5:     public void UpdateEmailAddress(string  emailAddress)
       6:     { 
       7:         //Email地址修改操作
       8:     } 
       9:     //其他成员
      10: }

    对于上面定义的UpdateEmailAddress方法,由于应用了AuthorizeAttribute特性,意味着只有认证的用户才能调用它来修改自己提供的Email地址。此外,HttpPostAttribute特性应用在该Action方法上,使我们只能以POST请求的方式调用它,这无形之中也增强了安全系数。但是这个方法提供的Email修改功能真的安全吗?它真的确保修改后的Email地址真的是登录用户提供的Email地址吗?

    我们假设BlogController所在的Web应用部署的域名为Foo,那么Action方法UpdateEmailAddress对应的地址可以表示为http://foo/blog/updateemailaddress。现在一个恶意攻击者创建如下一个简单的HTML页面,该页面具有一个指向上面这个地址的表单,并且该表单中具有一个名为emailAddress <input>元素提供属于供给者自身的Email地址。由于注册了window的onload事件,该表单会在页面加载完成之后自动提交。

       1: <html>
       2: <head>
       3:     <script type="text/javascript">
       1:  
       2:         window.onload = function () {
       3:             document.getElementById("updateEmail").submit();
       4:         }
       5:     
    </script>
       4: </head>
       5: <body>
       6: <form id="updateEmail" action="http://foo/blog/updateemailaddress" 
       7:     method="post">       
       8:         <input type="hidden" name="emailAddress" value="malicious@gmail.com" />
       9:     </form>
      10: </body>
      11: </html>

    假设攻击者部署该页面的地址为http://bar/maliciouspage.html。然后它通过某篇博文中添加一个包含如下链接的评论。作为登录用户的你点击该连接后将会间接地调用定义在BlogController的UpdateEmailAddress方法。由于登录用户的安全令牌一般以Cookie形式存在,而该Cookie会存在于发送给针对Action方法UpdateEmailAddress的调用请求中,服务器会认为该请求来自被认证用户,所以最终造成了你的Email地址被恶意修改而不自知。如果攻击者具有你的用户名,它可以通过重置密码,是新的密码发送到属于他自己的电子邮箱中。

       1: <img src="http://bar/maliciouspage.html"/>

    这个例子充分说明了CSRF是一种比较隐蔽并且具有很大危害型的网络攻击,促成攻击的原因在于服务器在针对某个请求执行某个操作的时候并没有验证请求的真正来源。对于ASP.NET MVC来说,如果我们在执行某个Action方法之前能够确认当前的请求来源的有效性,就能从根本上解决CSRF攻击,而ValidateAntiForgeryTokenAttribute结合HtmlHelper的AntiForgeryToken方法有效地解决了这个问题。

       1: public class HtmlHelper
       2: {
       3:     //其他成员
       4:     public MvcHtmlString AntiForgeryToken();
       5:     public MvcHtmlString AntiForgeryToken(string salt);
       6:     public MvcHtmlString AntiForgeryToken(string salt, string domain, string path);
       7: }

    如上面的代码片断所示,HtmlHelper具有三个AntiForgeryToken方法(这里的方式是HtmlHelper的实例方法,不是扩展方法)。当我们在一个View中调用这些方法是,它们会为我们生成一个所谓“防伪令牌(Anti-Forgery Token)”的字符串,并以此生成一个类型为Hidden的<input>元素。除此之外,该方法的调用还会根据这个防伪令牌设置一个Cookie。接下来我们来详细地来讨论这个过程。

    上述的这个防伪令牌通过内部类型为AntiForgeryData的对象生成。如下面的代码片断所示,AntiForgeryData具有四个属性,其核心是通过属性Value表示的值。属性UserName和CreationDate表示访问令牌授权的用户名和创建时间。字符串属性Salt是为了增强防伪令牌的安全系数,不同的Salt值对应着不同的防伪令牌,不同的防伪令牌在不同的地方被使用以避免供给者对一个防伪令牌的破解而使整个应用受到全面的攻击。ValidateAntiForgeryTokenAttribute也具有一个同名的属性。

       1: internal sealed class AntiForgeryData
       2: {   
       3:     public string Value { get; set; }
       4:     public string Salt { get; set; }    
       5:     public DateTime CreationDate { get; set; }
       6:     public string Username { get; set; }    
       7: }

    当AntiForgeryToken被调用的时候,会先根据当前的请求的应用路径(对应HttpRequest的ApplicationPath属性)计算出表示防伪令牌Cookie的名称,该名称会在通过对应用路径进行Base64编码(编码之前需要进行一些特殊字符的替换工作)生成的字符串前添加“__RequestVerificationToken”前缀。

    如果当前请求具有一个同名的Cookie,则直接通过对Cookie的值进行反序列化得到一个AntiForgeryData对象。需要注意的是,这里针对AntiForgeryData进行序列化和反序列化并不是一个简单地实现运行时对象到字符串之间的转换,还包含采用MachineKey对AntiForgeryData的四个属性进行加密/解密的过程。如果这样的Cookie不存在,HtmlHelper会随机生成一个长度为16的字节数组,并将对该字节数组进行Base64编码后生成的字符串作为值创建一个AntiForgeryData对象。系统当前时间(UTC)作为该AntiForgeryData对象的创建时间,但是该AntiForgeryData对象的UserName和Salt属性为空。

    接下来HtmlHelper会根据之前计算出来的Cookie名称创建一个)HttpCookie对象,而新创建出来的AntiForgeryData对象被序列化后生成的字符串作为该HttpCookie的值。如果我们在AntiForgeryToken方法调用中设置了表示域和路径的domain和path参数,它们将会作为该HttpCookie对象的Path和Domain属性。最后HtmlHelper将HttpCookie对象设置给当前的HTTP响应。

    AntiForgeryToken返回的是一个类型为hidden的<input>元素对应的HTML,该Hidden元素的名称为“__RequestVerificationToken”(即代码访问令牌Cookie名称的前缀)。为了生成该Hidden元素的值,HtmlHelper会根据现有的AntiForgeryData对象(从当前请求获取的或者新创建的)创建一个新的AntiForgeryData对象,两个对象具有相同的CreationDate和Value属性,而当前用户名和指定的Salt参数将会设置给新AntiForgeryData对象的UserName和Salt属性。

       1: @using (Html.BeginForm())
       2: {
       3:   @Html.AntiForgeryToken("647B8734-EFCA-4F51-9D98-36502D13E4E7")
       4:   ...
       5: }

    在一个View中我们通过如上的代码在一个表单中调用HtmlHelper的AntiForgeryToken方法并将一个GUID作为Salt,最终将会生成如下一个名为“__RequestVerificationToken”的Hidden元素。

       1: <form action="..." method="post">
       2: <input name="__RequestVerificationToken" type="hidden" value="yvLaFQ81JVgguKECyF/oQ+pc2/6q0MuLEaF73PvY7pvxaE68lO5qgXZWhfqIk721CBS0SJZjvOjbc7o7GL3SQ3RxIW90no7FcxzR6ohHUYEKdxyfTBuAVjAuoil5miwoY8+6HNoSPbztyhMVvtCsQDtvQfyW1GNa7qvlQSqYxQW7b6nAR2W0OxNi4NgrFEqbMFrD+4CwwAg4PUWpvcQxYA==" />
       3: ...
       4: </form>

    对于该View的首次访问(或者对应的Cookie不存在),如下所示的名称为“__RequestVerificationToken_L012Y0FwcDEx”防伪令牌Cookie将会设置,并且是HttpOnly的。

       1: HTTP/1.1 200 OK
       2: Cache-Control: private
       3: ...
       4: Set-Cookie: __RequestVerificationToken_L012Y0FwcDEx=EYPOofprbB0og8vI+Pzr1unY0Ye5BihYJgoIYBqzvZDZ+hcT5QUu+fj2hvFUVTTCFAZdjgCPzxwIGsoNdEyD8nSUbgapk8Xp3+ZD8cxguUrgl0lAdFd4ZGWEYzz0IN58l5saPJpuaChVR4QaMNbilNG4y7xiN2/UCrBF80LmPO4=; path=/; HttpOnly
       5: ...

    对于一个请求,如果确保请求提供的表单中具有一个名为“__RequestVerificationToken”的Hidden元素,并且该元素的值与对应的防伪令牌的Cookie值相匹配,就能够确保请求并不是由第三方恶意站点发送的,进而防止CSRF攻击。原因很简单:由于Cookie值是经过加密的,供给者可以得到整个Cookie的内容,但是不能解密获得具体的值(AntiForgeryData的Value属性),所以不可能在提供的表单中也包含一个具有匹配值的Hidden元素。针对防伪令牌的验证就实现在ValidateAntiForgeryTokenAttribute的OnAuthorization方法中。

    我们来具体介绍一下实现在ValidateAntiForgeryTokenAttribute中针对防伪令牌的验证逻辑。首先它根据当前请求的应用路径采用与生成防伪令牌Cookie相同的逻辑计算出Cookie名称。如果对应的Cookie不存在于当前请求中,则直接抛出HttpAntiForgeryException异常;否则获取Cookie值,并反序列化生成一个AntiForgeryData对象。

    然后从提交的表单中提取一个名称为“__RequestVerificationToken”的输入元素,如果这样的元素不存在,同样抛出HttpAntiForgeryException异常;否则直接对具体的值进行反序列化生成一个AntiForgeryData对象。最后ValidateAntiForgeryTokenAttribute对这两个AntiForgeryData的Value属性进行比较,以及后者的UserName和Salt属性与当前用户名和自身的Salt属性值进行比较,任何一个不匹配都会抛出HttpAntiForgeryException异常。

    六、ChildActionOnlyAttribute

    如果我们希望定义在Controol中的方法能以子Action的形式在某个View中被调用,这样的调用一般用于生成组成整个View的某个部分的HTML,我们可以在方法上应用ChildActionOnlyAttribute特性。从如下给出的定义可以看出,ChildActionOnlyAttribute实际上是一个AuthorizationFilter,它在重写的OnAuthorization方法中对当前请求进行验证,对于非子Action调用下直接抛出InvalidOperationException异常。

       1: [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple=false, Inherited=true)]
       2: public sealed class ChildActionOnlyAttribute : FilterAttribute, IAuthorizationFilter
       3: {
       4:     public void OnAuthorization(AuthorizationContext filterContext);
       5: }

    有的读者可能会问,AuthorizationFilter如何区分当前的请求是基于子Action的调用,而不是一般的Action调用呢?其实很简单,当我们在调用HtmlHelper的扩展方法Action或者RenderAction的时候会将当前的View上下文作为“父View上下文”保存到表示当前路由信息的RouteData的DataTokens属性中,对应的Key为“ParentActionViewContext”。如下面的代码片断所示,ControllerContext中用于判断是否为子Action请求的IsChildAction属性正式通过该路由信息进行判断的。

       1: public class ControllerContext
       2: {
       3:     //其他成员
       4:     public virtual bool IsChildAction
       5:     {
       6:         get
       7:         {
       8:             RouteData routeData = this.RouteData;
       9:             if (routeData == null)
      10:             {
      11:                 return false;
      12:             }
      13:             return routeData.DataTokens.ContainsKey("ParentActionViewContext");
      14:         }
      15:     }
      16: }
    作者:Artech
    出处:http://artech.cnblogs.com/
    本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。
  • 相关阅读:
    QOMO Linux 4.0 正式版发布
    LinkChecker 8.1 发布,网页链接检查
    pgBadger 2.1 发布,PG 日志分析
    Aletheia 0.1.1 发布,HTTP 调试工具
    Teiid 8.2 Beta1 发布,数据虚拟化系统
    zLogFabric 2.2 发布,集中式日志存储系统
    开源电子工作套件 Arduino Start Kit 登场
    Piwik 1.9 发布,网站访问统计系统
    Ruby 1.9.3p286 发布,安全修复版本
    toBraille 1.1.2 发布,Java 盲文库
  • 原文地址:https://www.cnblogs.com/Leo_wl/p/2573828.html
Copyright © 2011-2022 走看看