zoukankan      html  css  js  c++  java
  • Seam 2.1中的安全升级

    by Shane Bryzak,翻译:JSF中文技术文摘

    Article permalink: http://shane.bryzak.com/articles/seam_security_gets_an_upgrade

    即将到来的 JBoss Seam 2.1.0.GA 发布版本将包含一些新的功能和增强的安全特性,这些安全特性包括 Identity Management, 基于ACL的权限, 和 Permission Management, 还有强类型(strongly-typed)的安全注解。在这篇文章中,我将结合在SeamSpace示例(在Seam发布包的/examples/seamspace目录下)来讲解下这些新的安全特性。

    如果你想自己来运行下SeamSpace示例程序,下面有些建议来指导你如何做:

    1) 首先,确保你安装了JDK 1.5,还有 Apache Ant(最近发布版本)
    2) 下载并安装 JBoss AS 4.2.2, 下载地址: http://www.jboss.org/jbossas/downloads/
    3) 下载并且解压缩Seam的2.1每日建构版本,下载地址: http://www.seamframework.cn/Download (如果你具有探索精神,可以从SVN中检出最新版本,地址:

    http://anonsvn.jboss.org/repos/seam/trunk/)
    4) 编辑Seam目录中的 build.properties 文件,修改你的 JBoss AS 安装目录
    5) 导航到 examples/seamspace 目录,运行 'ant'
    6) 在 JBoss bin目录下运行run脚步来启动JBoss AS
    7) 在 JBoss AS 启动后,打开 http://localhost:8080/seam-space

    现在我们开始看看新的 Identity Management 特性。

    Identity Management

    那么什么是 Identity Management 呢? 直到现在,Seam只提供了使用内建的组件来做用户验证(Identity 组件)。Seam没有提供的是一个用于创建和管理实际用户帐户的API,

    你可以使用这些API来验证用户信息,这留给了开发者。Identity Management通过提供这样一个API来填补了这个空白,这个API努力使用一致的方式来管理用户和角色,而不用考

    虑他们存储在那里。无论他们是作为记录保存在关系数据库中还是作为实体保存在LDAP目录中,Identity Management都在Seam程序中提供了统一标准的API来创建,更新,和删除

    用户和角色。

    刚才所说的 Identity Management API 就是 IdentityManager 组件。这个组件暴露了一些identity management的操作。为了让你更多的了解下这个特性,下面列出了一些它具有

    的函数:

        * createUser(String username, String password)
        * deleteUser(String name)
        * enableUser(String name)
        * disableUser(String name)
        * changePassword(String name, String password)
        * isUserEnabled()
        * grantRole(String name, String role)
        * revokeRole(String name, String role)
        * createRole(String role)
        * deleteRole(String role)
        * userExists(String name)
        * roleExists(String name)
        * listUsers()
        * authenticate(String username, String password)

    从上表中你可以看到,大多数的函数都和操作系统安全相关的命令同义(假设你属性unix/linux),就像:adduser,deluser 等待。


    Configuring IdentityManager

    IdentityManager的配置是相当简单的 -- 它需要和一个或则两个认证中心(identity stores)一起配置。一个identity store 用于所有用于相关的操作,另外一个用于角色相关

    的操作。如果你的用户和角色相关信息都保存在同一个存储中心中(例如一个数据库),这样只需要一个identity store就可以了,这一个identity store用作用户和角色相关的

    操作。这听起来好像有点奇怪,当处理复杂的安全需求时,这有相当大的伸缩性。 例如这样一个场景:从LDAP中验证用户,但是从一个关系数据库中载入用户的角色信息。

    每个 IdentityStore 实现都知道如何与一个特别的存储中心打交道。Seam提供了两个现成的 IdentityStore 实现,JpaIdentityStore 和 LdapIdentityStore, 分别用于数据库和

    基于LDAP的安全验证。如果IdentityManager没有配置identity stores,那么它就使用默认值JpaIdentityStore。我们以后在来讲解如何使用LdapIdentityStore,现在这篇文章中

    我们就只关注JpaIdentityStore。


    JpaIdentityStore

    这个 IdentityStore 实现可以让你把用户帐户信息保存在数据库中。通过应用特定的注解到代表用户和角色的实体bean上,JpaIdentityStore可以使用这些实体来管理用户数据库

    中的用户信息。

    我们来看看用来保存用户帐户信息的 MemberAccount 实体 bean。 下面的代码为了排版被截断了 :

    @Entity
    @Table(uniqueConstraints = @UniqueConstraint(columnNames = "username"))
    public class MemberAccount implements Serializable
    {
         // snip field and key declarations
       
         @NotNull @UserPrincipal
         public String getUsername() { return username; }
         public void setUsername(String username) { this.username = username; }
       
         @UserPassword(hash = "MD5")
         public String getPasswordHash() { return passwordHash; }
         public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }    
       
         @UserEnabled
         public boolean isEnabled() { return enabled; }
         public void setEnabled(boolean enabled) { this.enabled = enabled; }

         @UserRoles
         @ManyToMany(targetEntity = MemberRole.class)
         @JoinTable(name = "AccountMembership",
               joinColumns = @JoinColumn(name = "AccountId"),
               inverseJoinColumns = @JoinColumn(name = "MemberOf"))
         public Set<MemberRole> getRoles() { return roles; }
         public void setRoles(Set<MemberRole> roles) { this.roles = roles; }
    }

    就如我们看到的,在bean的属性访问方法中有一些附加的注解,这些注解告诉 JpaIdentityStore 如何与特定的实体bean交互。我们下面来详细的看看这些注解:

        * @UserPrincipal - 该注解表明该属性包含用户的主要属性(i.e. 用户名称)
        * @UserPassword - 该注解表明该属性包含用户的密码。把用户密码保存为普通文本通常不是一个好的注意,因此这个注解支持使用hash算法。该算法用来产生一个用户密码

    的hash值,然后会把hash值保存到数据库中。
        * @UserEnabled - 这个是可选的,用户指示该用户帐户是否是启用的,默认值为启用所有的帐户。
        * @UserRoles - 这个注解指示该用户所属的角色组。

    这里还有一组类似的注解来配置一个用来保存角色的实体bean。还可以把角色信息和用户信息保存在同一个表中,通过鉴别器来区分,请参考Seam的文档来了解相关信息。

    Alternatively, it is possible to store role records in the same table as users (which then becomes self-referencing via a one-to-many relationship to itself) by specifying one of the columns as a discriminator (determining whether the record represents a user or a role).  


    Using IdentityManager


    我们来看看SeamSpace示例, 看看Identity management是如何工作的


    从上图,SeamSpace的主页,我们可以看到一个让用户注册的'SIGN UP'链接。在注册页面用户可以输入一些帐户信息来注册,然后可以用来登陆。注册的程序使用IdentityManager

    来创建新的用户帐户,然后登陆新注册的用户。看一下 RegisterAction.java 类,我们可以看到 Identity Manager 通过使用 @In 注解注入进来了:

       @In
       private IdentityManager identityManager;


    更进一步,我们可以在uploadPicture()方法中看到新用户是如何被创建的 (在注册流程的最后调用了该方法,并且结束了当前的会话):

       @End
       public void uploadPicture()
       {
          (snip)

          new RunAsOperation() {
             public void execute() {
                identityManager.createUser(username, password);
                identityManager.grantRole(username, "user");          
             }       
          }.addRole("admin")
           .run();
         
         (snip)
       
          // Login the user
          identity.setUsername(username);
          identity.setPassword(password);
          identity.login();
       }

    RunAsOperation 使用较高的优先级来执行特别的操作。在这种情况下,创建一个新用户要求当前的用户具有管理员的权限,上面的代码通过addRole()函数暂时为RunAsOperation

    操作授权。在该函数的最后我们看到该用户使用用户名和密码登陆到系统中了。

    Authentication


    以前,Seam安全管理在认证阶段需要调用一个'authenticator' 组件,该组件用来计算用户的角色。这个认证模型当前依然支持,同时使用IdentityManager 来认证用户更合常理

    ,他提供了认证功能而不用写额外的待命。请记住,要使用IdentityManager 来验证用户,只需要从components.xml文件的identity 组件配置中删除'authenticate-method'属性

    就可以了:

        <!-- The old way of authenticating, using an authenticator component -->
        <!--security:identity authenticate-method="#{authenticator.authenticate}"/-->
       
        <!-- The new way to authenticate, using IdentityManager (you don't actually need to
             include this element, since it has no attributes now) -->
        <security:identity/>

    User Management Views



    如果你使用管理员帐号登陆 SeamSpace (username/password: demo/demo),在顶部你会看到一个'Security' 连接. 点击这个连接可以用来管理其他的用户和角色:

    点击第一个连接, 'Manage Users' 将会打开用户管理页面:
    这里可以添加,修改和删除用户。点击 'new user' 按钮来创建用户, 打开一个输入用户信息的页面:
    点击 save 会保存新用户,返回到用户管理界面,可以看到新创建的用户:
    现在来看看角色管理, 点击'Manage Roles' 会打开角色管理页面:
    我们会看到系统定义的角色。点击'new role'来创建新的角色:
    可以输入角色名和指定用户组。点击save保存,然后返回的角色管理界面:



    这里我们就结束了 identity management 特效的预览,下面来看看 permission management.

    Permission Management
    尽管 Identity Management 提供了一致的API来管理用户帐户,我们依然需要一个方式来管理用户许可(Permission)。从前一个版本以来,在Seam 2.1.0中的验证特性经过了整个的修订。在以前开发者需要继承一个内建的 Identity 组件来实现自定义的许可(Permission)检查,在Seam 2.1.0中提供了一个可插拔的系统,可以让你注册你自己的许可(Permission)分析器而不用继承其他的组件了。下图展示了这些东西是如何协作 的:




    在上图中, Identity 现在使用 PermissionMapper 映射一个特别的ResolverChain来执行许可(Permission)检查,该ResolverChain可以配置一个或多个 PermissionResolvers。Seam提供了 RuleBasedPermissionResolver (for resolving rule-based permission checks) 和 PersistentPermissionResolver (for performing checks based on permissions stored in persistent storage, such as a database)。如果你的程序需要自定义安全管理,实现你自己的PermissionResolver 也是相当简单的。

    我想在进一步前进之前,我们需要定义下许可(Permission)是什么。在Seam中,许可(Permission)有3个方面:

        * A target, 一个在某些方面起作用的对象
        * An action, 在 target 上被执行的动作
        * A recipient, 具有在target上执行特定action的许可(Permission)的用户或则角色实体
       
       

       
    许可(Permission)检查的target就是PermissionMapper用来判断使用那个ResolverChain来执行检查。这样可以 实现为不同的对象配置不同的PermissionResolvers。例如:你可能希望仅仅使用RuleBasedPermissionResolver 来执行Customer对象的许可(Permission)检查,同时使用PersistentPermissionResolver来执行 Invoice对象上的许可(Permission)检查。PermissionMapper支持这种灵活性。
    让我们实际的看看这些应用。


    Persistent Permissions

    SeamSpace示例允许用户上传一个图片。其他用户在浏览用户信息的时候可以看到该图片:




    现在我们假设一些图片你想设置为私有的,而其他一些图片你想仅仅显示给你的朋友。点击你图片下的 padlock 图标,会打开图片的许可(Permission)管理页面:


    在这个页面,我们可以看到那些用户和角色有查看这个图片的许可。在这个示例中,仅仅我的朋友具有查看该图片的权限。现在我想让该站点的任何用户都可以看到该图片。可以点击'new permission'来授权,会打开许可详细信息页面:



    在这里我们通过选择角色或则具体的用户来提供特殊的许可权限。我希望所有的用户都可以查看,所以我从角色列表中选择‘user’,然后点击‘view’ checkbox。点击save按钮保存设置,然后返回到许可管理界面,在这里可以看到我设置的新许可:



    当我们授予一个新的许可的时候,实际发生了什么呢?我们看看在这个场景后面使用的组件,ImagePermission. 下面是相关的代码:

    @Name("imagePermission")
    @Scope(CONVERSATION)
    public class ImagePermission implements Serializable
    {
       // (snip)
       @In PermissionManager permissionManager;
       @In PermissionSearch permissionSearch;

       private MemberImage target;  
       private Principal recipient;

       @SuppressWarnings("unchecked")
       @Begin(nested = true)
       public void createPermission() {
          target = (MemberImage) permissionSearch.getTarget();    
          // (snip)
       }

       public void applyPermissions() {
          // (snip)
      
          List<Permission> permissions = new ArrayList<Permission>();
        
          for (String role : selectedRoles)
          {
             Principal r = new Role(role);
             for (String action : selectedActions)
             {          
                permissions.add(new Permission(target, action, r));
             }
          }
         
          for (Member friend : selectedFriends)
          {
             MemberAccount acct = (MemberAccount) entityManager.createQuery(
                   "select a from MemberAccount a where a.member = :member")
                   .setParameter("member", friend)
                   .getSingleResult();
           
             Principal p = new SimplePrincipal(acct.getUsername());
           
             for (String action : selectedActions)
             {
                permissions.add(new Permission(target, action, p));
             }
          }
         
          permissionManager.grantPermissions(permissions);

          Conversation.instance().endBeforeRedirect();
       }

       // (snip)
    }

    从上面的代码中我们可以看到 ImagePermission 是一个会话作用域(conversation-scoped)的组件。其实,它的功能被实现为一个嵌套的作用域,在同一个target对象上可以同时打开 多个 'new permission' 窗口来操作。我们还可以看到PermissionManager通过@In 注解注入进来了。


    现在 createPermission() 方法开始一个嵌套的会话 (thanks to the @Begin(nested = true) annotation),然后在我们的target对象中保存一个引用。当用户点击save按钮,在分配了指定的许可后,用来构建一些 Permission对象来执行授权的applyPermissions()方法被调用了。在该函数内,使用一些授权的许可来调用 PermissionManager.grant()。我们发些时间来详细解释下这个 PermissionManager (the heart of the Permission Management API)。


    The PermissionManager Component

    就像 IdentityManager 用来处理用户和角色操作一样,PermissionManager 被设计为来操作许可。它提供了API来允许授权和激活许可,或则一个target对象的列表。我们来看看一些方法:

        * listPermissions(String target, String action)
        * listPermissions(Object target)
        * grantPermission(Permission permission)
        * grantPermissions(List<Permission> permissions)
        * revokePermission(Permission permission)
        * revokePermissions(List<Permission> permissions)

    就像 IdentityManager 需要一个 IdentityStore 来保存数据, PermissionManager 也需要一个 PermissionStore 来和持久化存储打交到。Seam 只提供了一个 PermissionStore 实现 - JpaPermissionStore, 可以通过JPA来和数据库存在互操作。在理论上也可以把许可信息保存在LDAP目录中,或则一个普通文件中,但是大多数情况下还是保存在一个数据库中的。

    现在来看看SeamSapce示例中 AccountPermission entity bean的代码,下面的代码为了排版被截断了:


    @Entity
    public class AccountPermission implements Serializable
    {
       // snip field declarations, etc
        
       @PermissionUser @PermissionRole
       public String getRecipient() { return recipient; }
       public void setRecipient(String recipient) { this.recipient = recipient; }

       @PermissionTarget public String getTarget() { return target; }
       public void setTarget(String target) { this.target = target; }

       @PermissionAction
       public String getAction() { return action; }
       public void setAction(String action) { this.action = action; }

       @PermissionDiscriminator
       public String getDiscriminator() { return discriminator; }
       public void setDiscriminator(String discriminator) { this.discriminator = discriminator; }
    }

    再一次的,我们注意到使用了一些特殊的注解。这里我告诉你,许可既可以指定到用户上也可以指定到角色上。这就意味着可以把许可信息保存的分开的表格中,这 从性能角度来说,把他们保存在单个表中,使用一个辨别器列(discriminator column)来区分他们,可能看起来更合适。因此在上面的代码中,我们看到getDiscriminator() 使用了@PermissionDiscriminator注解,表明该列用来判断许可是应用到用户上还是角色上。

    继续向下看,用来配置一个保存许可信息的实体注解如下:

        * @PermissionUser - designates the field that contains the name of the recipient of the permission (for user-assigned permissions)
        * @PermissionRole - same as above, but for role-assigned permissions
        * @PermissionTarget - contains a unique identifier string, identifying a single instance of an object. Alternatively, can contain a class name or any arbitrary string for the designation of more generalised permissions.
        * @PermissionAction - contains a list of the actions that the recipient may perform on the target object.
        * @PermissionDiscriminator - see paragraph above


    这里说明下 - permission management 特性只用来管理持久的许可信息( persistent permissions)。当然在大多数情况下,你可能希望通过业务逻辑来应用许可权限,例如:用户应该具有查看和管理他们自己图片的权限。这类许可有 Seam的rule-based security(基于角色的安全)来处理,在下面我们来详细讨论下这个问题。


    Rule-based Permissions


    Seam 基于Drools 提供了一个rule-based security模型,因此这并不是一个真正的新功能。我们这里来重新温习下这是如何应用到我们的示例项目SeamSpace中的。

    继续图片安全这个话题,当处理用户图片的时候,除了我们上面讨论的问题外,显然我们需要一些基础的安全规则。在默认的情况下,查看用户的图片被安全规则限 制了,这意味着在SeamSpace中,如果你想看一个用户的图片,你必须在被该用户授权后才可以查看(either via a persistent permission grant, or a security rule)。另外请记住,我们特别的需要允许下面的一些事情:

        * Users should be allowed to grant and revoke permissions for their own images
        * Users should be allowed to delete their own images
        * User profile images (a user's main image, i.e. their 'avatar') should always be viewable by anyone
        * Users should always be allowed to view their own images (of course)
        * User images with 'friend' permissions should be viewable by the user's friends (more on this in a bit)

    现在我们详细的看看这些规则。首先,用户应该允许给他们的图片授予和撤回许可权限。这是相当直接的,并且作为两个独立的规则实现了。当和对象许可一起使用 的时候,Seam会插入一个PermissionCheck对象到Drools的工作内存(working memory)中,该内存包含许可目标和根据用户要做什么分别由'seam.grant-permission' 或则 'seam.revoke-permission'(based on whether PermissionManager.grantPermission() or PermissionManager.revokePermission() is called)定义的一个动作(containing both the target of the permission, and an action either being 'seam.grant-permission' or 'seam.revoke-permission' depending on what the user is trying to do (based on whether PermissionManager.grantPermission() or PermissionManager.revokePermission() is called))。用于验证用户的MemberAccount示例一直在working memory中,在实际上下面的规则是: '如果我们正在处理的MemberImage是属于当前登陆的用户的,那么就授予许可权限(if the MemberImage for which we're performing the permission check is owned by the current user, then grant the permission)':

    rule GrantImagePermissions
        no-loop
        activation-group "permissions"
    when
        acct: MemberAccount()
        image: MemberImage(mbr : member -> (mbr.memberId.equals(acct.member.memberId)))
        check: PermissionCheck(target == image, action == "seam.grant-permission", granted == false)
    then
        check.grant();
    end

    rule RevokeImagePermissions
        no-loop
        activation-group "permissions"
    when
        acct: MemberAccount()
        image: MemberImage(mbr : member -> (mbr.memberId.equals(acct.member.memberId)))
        check: PermissionCheck(target == image, action == "seam.revoke-permission", granted == false)
    then
        check.grant();
    end

    在下一步,我们也需要一个允许用户删除他们自己图片的规则。和第一个规则相似,我们验证当前执行删除的图片是否属性该用户的,如果是那么就授予权限:

    rule DeleteImage
        no-loop
        activation-group "permissions"
    when
        acct: MemberAccount()
        image: MemberImage(mbr : member -> (mbr.memberId.equals(acct.member.memberId)))
        check: PermissionCheck(target == image, action == "delete", granted == false)
    then
        check.grant();
    end

    用于查看用户图片的规则有点不同。这里我们简单的测试 -- 正在查看的图片是用户自己的图片(Here we simply test that the image being viewed is the profile image for the owning member) (i.e. image.getMember().getPicture() == image):

    rule ViewProfileImage
        no-loop
        activation-group "permissions"
    when
        image: MemberImage()
        check: PermissionCheck(target == image, action == "view", granted == false)
        eval( image.getMember().getPicture() == image )
    then
        check.grant();
    end

    还有,用户应该总是可以查看他们自己的图片。这个权限验证类似于第一个规则,在那里我们简单的验证图片的所有者是否为当前登陆的用户:

    rule ViewMyImages
        no-loop
        activation-group "permissions"
    when
        acct: MemberAccount()
        image: MemberImage(mbr : member -> (mbr.memberId.equals(acct.member.memberId)))
        check: PermissionCheck(target == image, action == "view")
    then
        check.grant();
    end
    Conditional Roles

    最后,为了当我们的朋友查看我们的图片,我们需要定义一个特殊的规则。在前面我们看到了如何给‘friends’角色授权,然而根据上下文的不同 ‘riend’可能具有不同的意思。可以认为,在系统中的任何一个用户都是某人的‘friend’,你们当其他人来看图片的时候我们如何定义他们是否是一 个‘friend’呢?这就是条件角色(conditional roles )的领域了,这些角色是特殊的,并且不能直接的授予给用户。

    当一个对象的权限检查通过安全(security)API检查过后, permission manager就通知 security API一个conditional role被授予权限了,需要执行一个特殊的基础规则(rule-based)来验证该用户是否具有该角色,但在仅仅在permission check的上下文中执行。要实现这个功能,和往常一样把一个PermissionCheck对象插入到包含target和action的working memory中,然而也会附加的插入一个RoleCheck对象,该对象包含用来对比的conditional role的名字。这样我们可以写一个安全规则来验证是否授予conditional role权限:

    rule FriendViewImage
        no-loop
        activation-group "permissions"
    when
        acct: MemberAccount()
        image: MemberImage(mbr : member -> (mbr.isFriend(acct.member)))
        PermissionCheck(target == image, action == "view")
        role: RoleCheck(name == "friends")
    then
        role.grant();
    end

    这个规则检查当前验证的用户是否在图片所有者的朋友列表中。如果在的话,角色就暂时的授予这个许可检查。在某些复杂安全规则的情况下这具有非常大的灵活性 ( This allows great flexibility in assigning complex security rules to dynamic groups of users (in this case, a user's friends list) that don't necessarily warrant having their own role/group, due to either impracticality or design restrictions).


    Strongly-typed Security Annotations

    最后,作为结束我们来看看一些新的安全注解。为了使Seam Security看起来更'Web Beansy',我们引入了一些用于现在组件方法的类型安全的注解。通过使用meta-annotations,我们可以提供一些安全注解来应用安全限制 到函数上或则参数上。Seam提供了一些开箱即用的注解,他们是标准的CRUD注解 (@Insert, @Read, @Update, @Delete) ,并且添加你自己的注解也是非常简单的。看看下面的例子:

    @Begin @Insert(Customer.class)
    public void createCustomer() {

    这个注解的功能是,除非你具有插入新customer对象的权利,放置你不能调用createCustomer方法。类似的,我们可以注解一个方法的参数:

    public void updateCustomer(@Update Customer customer) {

    创建自己的注解只需要使用@PermissionCheck就可以了。来看个示例,你希望创建一个新的‘Promote’许可。该注解可以非常简单的实现,如下:

    @Target({METHOD, PARAMETER})
    @Documented
    @Retention(RUNTIME)
    @Inherited
    @PermissionCheck
    public @interface Promote {
         Class value() default void.class;
    }

    定义后,就可以直接使用了:

    public void promoteStaff(@Promote Staff person) {

    如果写一个基于规则的许可,该规则看起来就像这样:

    rule PromoteStaffMember
        no-loop
        activation-group "permissions"
    when
        acct: MemberAccount()
        Role(name == 'admin')
        staff: Staff()
        check: PermissionCheck(target == staff, action == "promote")
    then
        check.grant();
    end

    许可动作(permission action)变为注解的小些名称了。这真的是很简单的!

    同时我们仍然支持遗留的用于基于表达式的安全检查的@Restrict注解,我建议各位都使用新的类型安全的注解,至少在编译期安全检查就提供了简单的验证。


    Conclusion

    到这里就结束了JBoss Seam中的新安全特性。这里是一些参考的链接:

    JBoss Seam Community Site (downloads, documentation, forums) - http://www.seamframework.org/

    JBoss Seam 中文社区站点(下载,文档,论坛) - http://www.seamframework.cn/

    JBoss Home Page - http://www.jboss.org


    转自:http://hi.baidu.com/jsfcn/
  • 相关阅读:
    jmeter巧用自增长型变量
    jmeter实现一次登录,多次业务请求(不同线程组间共享cookie和变量)
    jmeter实现IP欺骗
    基于jmeter+ant实现的接口自动化测试
    基于Robot Framework的接口自动化测试
    至少与至少
    code+7正式赛划水记+HardA~C题解
    code+7彩蛋题题解
    开发一个博客园系统
    beautifulSoup模块
  • 原文地址:https://www.cnblogs.com/lanzhi/p/6469981.html
Copyright © 2011-2022 走看看