分享

JAVA安全三部曲之一JAAS编程指南(前篇)

 石榴 2007-08-28

 

1          JAAS

  Java Authentication Authorization ServiceJAASJava验证和授权API)提供了灵活和可伸缩的机制来保证客户端或服务器端的Java程序。Java早期的安全框架强调的是通过验证代码的来源和作者,保护用户避免受到下载下来的代码的攻击。JAAS强调的是通过验证谁在运行代码以及他/她的权限来保护系统面受用户的攻击。它让你能够将一些标准的安全机制,例如Solaris NIS(网络信息服务)、Windows NTLDAP(轻量目录存取协议),Kerberos等通过一种通用的,可配置的方式集成到系统中。本文首先向你介绍JAAS验证中的一些核心部分,然后通过例子向你展示如何开发登录模块。

  你是否曾经需要为一个应用程序实现登录模块呢?如果你是一个比较有经验的程序员,相信你这样的工作做过很多次,而且每次都不完全一样。你有可能把你的登录模块建立在Oracle数据库的基础上,也有可能使用的是NT的用户验证,或者使用的是LDAP目录。如果有一种方法可以在不改变应用程序级的代码的基础上支持上面提到的所有这一些安全机制,对于程序员来说一定是一件幸运的事。

  现在你可以使用JAAS实现上面的目标。JAAS是一个比较新的的Java API。在J2SE 1.3中,它是一个扩展包;在J2SE 1.4中变成了一个核心包。在本文中,我们将介绍JAAS的一些核心概念,然后通过例子说明如何将JAAS应用到实际的程序中。本文的例子是根据我们一个基于WebJava应用程序进行改编的,在这个例子中,我们使用了关系数据库保存用户的登录信息。由于使用了JAAS,我们实现了一个健壮而灵活的登录和身份验证模块。

1.1       JAAS概述

  在JAAS出现以前,Java的安全模型是为了满足跨平台的网络应用程序的需要而设计的。在Java早期版本中,Java通常是作为远程代码被使用,例如Applet,。因此最初的安全模型把注意力放在通过验证代码的来源来保护用户上。早期的Java安全机制中包含的概念,如SercurityManager,沙箱概念,代码签名,策略文件,多是为了保护用户。

 

  JAAS的出现反映了Java的演变。传统的服务器/客户端程序需要实现登录和存取控制,JAAS通过对运行程序的用户的进行验证,从而达到保护系统的目的。虽然JAAS同时具有验证和授权的能力,在这篇文章中,我们主要介绍验证功能。

 

  通过在应用程序和底层的验证和授权机制之间加入一个抽象层,JAAS可以简化涉及到Java Security包的程序开发。抽象层独立于平台的特性使开发人员可以使用各种不同的安全机制,而且不用修改应用程序级的代码。和其他Java Security API相似,JAAS通过一个可扩展的框架:服务提供者接口(Service Provider InterfaceSPI)来保证程序独立于安全机制。服务提供者接口是由一组抽象类和接口组成的。图一中给出了JAAS程序的整体框架图。应用程序级的代码主要处理LoginContext。在LoginContext下面是一组动态配置的LoginModulesLoginModule使用正确的安全机制进行验证。

 

  图一给出了JAAS的概览。应用程序层的代码只需要和LoginContext打交道。在LoginContext之下是一组动态配置的LoginModule对象,这些对象使用相关的安全基础结构进行验证操作。

 

                                                               图一 JAAS概览

JAAS提供了一些LoginModule的参考实现代码,比如JndiLoginModule。开发人员也可以自己实现LoginModule接口,就象在我们例子中的RdbmsLonginModule。同时我们还会告诉你如何使用一个简单的配置文件来安装应用程序。

 

  为了满足可插接性,JAAS是可堆叠的。在单一登录的情况下,一组安全模块可以堆叠在一起,然后被其他的安全机制按照堆叠的顺序被调用。

  JAAS的实现者根据现在一些流行的安全结构模式和框架将JASS模型化。例如可堆叠的特性同Unix下的可堆叠验证模块(PAMPluggable Authentication Module)框架就非常相似。从事务的角度看,JAAS类似于双步提交(Two-Phase Commit2PC)协议的行为。JAAS中安全配置的概念(包括策略文件(Police File)和许可(Permission))来自于J2SE 1.2JAAS还从其他成熟的安全框架中借鉴了许多思想。

1.2       使用JAAS进行验证

在使用JAAS之前,你首先需要安装JAAS。在J2SE 1.4中已经包括了JAAS,但是在J2SE 1.3中没有。如果你希望使用J2SE 1.3,你可以从SUN的官方站点上下载JAAS。当正确安装了JAAS后,你会在安装目录的lib目录下找到jaas.jar。你需要将该路径加入Classpath中。(注:如果你安装了应用服务器,其中就已经包括了JAAS,请阅读应用服务器的帮助文档以获得更详细的信息)。在Java安全属性文件java.security中,你可以改变一些与JAAS相关的系统属性。该文件保存在<jre_home/lib/security目录中。

1.2.1   JAAS验证原理

JAAS的核心类和接口可以被分为三种类型,大多数都在javax.security.auth包中。在J2SE 1.4中,还有一些接口的实现类在com.sun.security.auth包中,如下所示:

u       普通类 SubjectPrincipalCredential(凭证)

 Subject类代表了一个验证实体,它可以是用户、管理员、Web服务,设备或者其他的过程。该类包含了三中类型的安全信息:

1)        身份(Identities):由一个或多个Principal对象表示

2)        公共凭证(Public credentials):例如名称或公共密钥

3)        私有凭证(Private credentials):例如口令或私有密钥

  Principal对象代表了Subject对象的身份。它们实现了java.security.Principaljava.io.Serializable接口。在Principal类中,最重要的方法是getName()。该方法返回一个身份名称。在Subject对象中包含了多个Principal对象,因此它可以拥有多个名称。由于登录名称、身份证号和Email地址都可以作为用户的身份标识,可见拥有多个身份名称的情况在实际应用中是非常普遍的情况。

在上面提到的凭证并不是一个特定的类或借口,它可以是任何对象。凭证中可以包含任何特定安全系统需要的验证信息,例如标签(ticket),密钥或口令。Subject对象中维护着一组特定的私有和公有的凭证,这些凭证可以通过Subject 方法getPrivateCredentials()和getPublicCredentials()获得。这些方法通常在应用程序层中的安全子系统被调用。

u       验证 LoginContextLoginModuleCallBackHandlerCallback

验证:LoginContext

  在应用程序层中,你可以使用LoginContext对象来验证Subject对象。LoginContext对象同时体现了JAAS的动态可插入性(Dynamic Pluggability),因为当你创建一个LoginContext的实例时,你需要指定一个配置。LoginContext通常从一个文本文件中加载配置信息,这些配置信息告诉LoginContext对象在登录时使用哪一个LoginModule对象。

  下面列出了在LoginContext中经常使用的三个方法:

n         login () 进行登录操作。该方法激活了配置中制定的所有LoginModule 象。如果成功,它将创建一个经过了验证的Subject对象;否则抛出LoginException异常。

n         getSubject () 返回经过验证的Subject对象

n         logout () 注销Subject对象,删除与之相关的Principal对象和凭证

  验证:LoginModule

  LoginModule是调用特定验证机制的接口。J2EE 1.4中包含了下面几种LoginModule的实现类:

n         JndiLoginModule 用于验证在JNDI中配置的目录服务

n         Krb5LoginModule 使用Kerberos协议进行验证

n         NTLoginModul 使用当前用户在NT中的用户信息进行验证

n         UnixLoginModule 使用当前用户在Unix中的用户信息进行验证

 

  同上面这些模块绑定在一起的还有对应的Principal接口的实现类,例如NTDomainPrincipalUnixPrincipal。这些类在com.sun.security.auth包中。

 

  LoginModule接口中包含了五个方法:

1)        initialize () 当创建一LoginModule实例时会被构造函数调用

2)        login () 进行验证,通常会按照登录条件生成若干个Principal对象

3)        commit () 进行Principal对象检验,按照预定义Principal条件检验Login生成的Principal对象,所有需要的条件均符合后,把若干个生成的Principal对象付给Subject对象,JAAS架构负责回传给LoginContext.

4)        abort () 当任何一个LoginModule对象验证失败时都会调用该方法。任何已经和Subject对象绑定的Principal对象都会被解除绑定。

5)        logout () 删除与Subject对象关联的Principal对象和凭证,消除Subject,Principal等认证对象。

 验证:CallbackHandlerCallback

  CallbackHandlerCallback对象可以使LoginModule对象从系统和用户那里收集必要的验证信息,同时独立于实际的收集信息时发生的交互过程。

  

  JAASjavax.sevurity.auth.callback包中包含了七个Callback的实现类和两个CallbackHandler的实现类:ChoiceCallbackConfirmationCallbackLogcaleCallbackNameCallbackPasswordCallbackTextInputCallbackTextOutputCallbackDialogCallbackHandlerTextCallBackHandlerCallback接口只会在客户端会被使用到。我将在后面介绍如何编写你自己的CallbackHandler类。

 

u       授权 PolicyAuthPermissionPrivateCredentialPermission

 

  

 

 

  

 

 

1.2.2   JAAS简单例子

在应用程序中使用JAAS验证通常会涉及到以下几个步骤,如下图所示:

1)        创建一个LoginContext的实例。

2)        为了能够获得和处理验证信息,客户端将若干个CallBackHandler对象作为参数传送给LoginContext

3)        JAAS通过配置文件查到相关的LoginModule处理类。

4)        通过调用LoginContextlogin()方法来进行验证,此处会回调所有传入CallBackHandlerhandle()方法,该方法通常会从外部收集一些被验证信息。

5)        通过使用login()方法返回的Subject对象实现一些特殊的功能(假设登录成功)。

下面是一个简单的例子用来说明以上的情况:

LoginContext lc = new LoginContext("MyExample");

try {

lc.login();

} catch (LoginException) {

// Authentication failed.

}

// Authentication successful, we can now continue.

// We can use the returned Subject if we like.

Subject sub = lc.getSubject();

Subject.doAs(sub, new MyPrivilegedAction());

  在运行这段代码时,后台进行了以下的工作。

2)        当初始化时,LoginContext对象首先在JAAS配置文件jaas.config中找到MyExample项,然后更具该项的内容决定该加载哪个LoginModule对象(参见图二)。

3)        在登录时,LoginContext对象调用每个LoginModule对象的login()方法。

4)        每个login()方法进行验证操作或获得一个CallbackHandle对象。

5)        CallbackHandle对象通过使用一个或多个CallBack方法同用户进行交互,获得用户输入。

6)        向一个新的Subject对象中填入验证信息。

要使上述例子顺利运行,还需要有相关的JAAS配置文件进行配置。

 图二描述了配置文件中各元素之间的关系

图二 JAAS的配置文件

  

上面我已经提到,JAAS的可扩展性来源于它能够进行动态配置,而配置信息通常是保存在文本。这些文本文件有很多个配置块构成,我们通常把这些配置块称作申请(Application)。每个申请对应了一个或多个特定的LoginModule对象。

 

  当你的代码构造一个LoginContext对象时,你需要把配置文件中申请的名称传递给它。LoginContext将会根据申请中的信息决定激活哪些LoginModule对象,按照什么顺序激活以及使用什么规则激活。

  配置文件的结构如下所示:

Application {

ModuleClass Flag ModuleOptions;

ModuleClass Flag ModuleOptions;

...

};

Application {

ModuleClass Flag ModuleOptions;

...

};

...

 

下面是一个名称为Sample的申请

Sample {

com.sun.security.auth.module.NTLoginModule Rquired debug=true;

}

  上面这个简单的申请指定了LoginContext对象应该使用NTLoginModule进行验证。类的名称在ModuleClass中被指定。Flag控制当申请中包含了多个LoginModule时进行登录时的行为:RequiredSufficientRequisiteOptional。最常用的是Required,使用它意味着对应的LoginModule对象必须被调用,并且必须需要通过所有的验证。由于Flag本身的复杂性,本文在这里不作深究。

  ModuleOption允许有多个参数。例如你可以设定调试参数为Truedebug=true),这样诊断输出将被送到System.out中。

  配置文件可以被任意命名,并且可以被放在任何位置。JAAS框架通过使用java.securty.auth.long.config属性来确定配置文件的位置。例如当你的应用程序是JaasTest,配置文件是当前目录下的jaas.config,你需要在命令行中输入:

java -Djava.security.auth.login.config=jass.config JavaTest

通过命令行方式进行登录验证

 

1.2.3   真实世界的例子

  为了说明JAAS到底能干什么,我在这里编写了两个例子。一个是简单的由命令行输入调用的程序,另一个是服务器端的JSP程序。这两个程序都通过用户名/密码的方式进行登录,然后使用关系数据库对其进行验证。

1)        控制台方式实现JAAS认证

具体实现流程如下,其实只是稍微扩展了一下上面的简单的例子:

  1. 实现RdbmsLoginModule类,该类可以对输入的信息进行验证。

  2. 编辑一个配置文件,告诉LoginContext如何使用RdbmsLoginModule

  3. 实现ConsoleCallbackHandler类,通过该类可以获取用户的输入。

  4. 编写应用程序代码。

 

  在RdbmsLoginModule类中,我们必须实现LoginModule接口中的五个方法。首先是initialize()方法:

 

public void initialize(Subject subject, CallbackHandler callbackHandler, Map sharedState, Map options)

{

this.subject = subject;

this.callbackHandler = callbackHandler;

this.sharedState = sharedState;

this.options = options;

 

url = (String)options.get("url");

driverClass = (String)options.get("driver");

debug = "true".equalsIgnoreCase((String)options.get("debug"));

}

 

  LoginContext在调用login()方法时会调用initialize()方法。RdbmsLoginModule的第一个任务就是在类中保存输入参数的引用。在验证成功后将向Subject对象中送入Principal对象和凭证。

 

  CallbackHandler对象将会在login()方法中被使用到。sharedState可以使数据在不同的LoginModule对象之间共享,但是在这个例子中我们不会使用它。最后是名为optionsMap对象。optionsLgoinModule对象传递在配置文件ModuleOption域中定义的参数的值。配置文件simple.conf如下所示:

SimpleLogin {

   com.lemon.jaas.RdbmsLoginModule required

   driver="org.gjt.mm.mysql.Driver"

   url="jdbc:mysql://localhost/jaasdb?user=root"

   debug="true";   

 

};

  在配置文件中,RdbmsLoginModule包含了五个参数,其中driverurluserpassword是必需的,而debug是可选阐述。driverurluserpassword参数告诉我们如何获得JDBC连接。当然你还可以在ModuleOption域中加入数据库中的表或列的信息。使用这些参数的目的是为了能够对数据库进行操作。在LoginModule类的initialize()方法中我们保存了每个参数的值。

      

  我们前面提到一个LoginContext对应的配置文件告诉它应该使用文件中的哪一个申请。这个信息是通过LgoinContext的构造函数传递的。下面是初始化客户端的代码,在代码中创建了一个LoginContext对象并调用了login()方法。

 

       LoginContext ctx = new LoginContext("SimpleLogin", new ConsoleCallbackHandler());

        ctx.login();

        Subject subj = ctx.getSubject();

 

        System.out.println("Login assigned these principals: ");

        Iterator it = subj.getPrincipals().iterator();

        while (it.hasNext()) {

            Principal pl = (Principal) it.next();

            System.out.println("\t" + pl.getName());

        }

        Subject.doAsPrivileged(subj, new MyAction(), null);

 

 

        ctx.logout();

 

  当LgoinContext.login()方法被调用时,它调用所有加载了的LoginModule对象的login()方法。在我们的这个例子中是RdbmsLoginModule中的login()方法。

 

  RdbmsLoginModule中的login()方法进行了下面的操作:

 

  1. 创建两个Callback对象。这些对象从用户输入中获取用户名/密码。程序中使用了JAAS中的两个Callback:NameCallbackPasswordCallback(这两个类包含在javax.security.auth.callback包中)。

  2. 通过将callbacks作为参数传递给CallbackHandlerhandle()方法来激活Callback

  3. 通过Callback对象获得用户名/密码。

  4. rdbmsValidate()方法中通过JDBC在数据库中验证获取的用户名/密码,这里省略。

  下面是RdbmsLoginModule中的login()方法的代码

 

public boolean login() throws LoginException {

if (callbackHandler == null)

throw new LoginException("no handler");

NameCallback nameCb = new NameCallback("user: ");

PasswordCallback passCb = new PasswordCallback("password: ", true);

callbacks = new Callback[] { nameCb, passCb };

callbackHandler.handle(callbacks);

String username = nameCb.getName();

String password = new String(passCb.getPassword());

success = rdbmsValidate(username, password);

return(true);

}

 

  在ConsoleCallbackHandler类的handle()方法中你可以看到Callback对象是如何同用户进行交互的:

 

 

 

 

  public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException {

        for (int i = 0; i < callbacks.length; i++) {

            if (callbacks[i] instanceof NameCallback) {

                NameCallback nc = (NameCallback) callbacks[i];

                System.err.print(nc.getPrompt());

                System.err.flush();

                String name = (new BufferedReader(new InputStreamReader(System.in))).readLine();

                nc.setName(name);

            } else if (callbacks[i] instanceof PasswordCallback) {

                PasswordCallback pc = (PasswordCallback) callbacks[i];

                System.err.print(pc.getPrompt());

                System.err.flush();

                String name = (new BufferedReader(new InputStreamReader(System.in))).readLine();

                pc.setPassword(name.toCharArray());

            } else {

                throw(new UnsupportedCallbackException(callbacks[i], "Callback handler not support"));

            }

        }

    }           

  

现在启动这个例子:

 

 

2)        web环境下进行JAAS验证

现在应用的最广泛的系统为B/S系统,这些系统一般使用关系型数据库来存储应用数据。所以讨论在Web环境下的JAAS使用方法,尤其有现实意义。

我们使用通用的Web技术来实现这个输入用户信息的过程,然后通过RdbmsLoginModule类验证它。我们将会使用到JSPServlet以及HTML表单等技术来说明这个用例。

  首先我们写一个Servlet类代替之前的应用程序登录入口类。此类可以命名为LoginServlet 下面是该类的代码,下面代码包括了JAAS认证和授权功能:

 

public class LoginServlet extends HttpServlet {

    protected void service(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws ServletException, IOException {

        String userName = httpServletRequest.getParameter("username");

        String password = httpServletRequest.getParameter("password");

        System.out.println("userName = " + userName);

        System.out.println("password = " + password);

        LoginContext ctx = null;

        try {

            ctx = new LoginContext("SimpleLogin", new PassiveCallbackHandler(userName,password));

            ctx.login();

        } catch (LoginException e) {

            e.printStackTrace();  //To change body of catch statement use File | Settings | File Templates.

        }

        Subject subj = ctx.getSubject();

        System.out.println("Login assigned these principals: ");

        Iterator it = subj.getPrincipals().iterator();

        while (it.hasNext()) {

            Principal pl = (Principal) it.next();

            System.out.println("\t" + pl.getName());

        }

        Subject.doAsPrivileged(subj, new MyAction(), null);

        try {

            ctx.logout();

        } catch (LoginException e) {

            e.printStackTrace();  //To change body of catch statement use File | Settings | File Templates.

        }

    }

}  

从此处就可以看出我们把Servlet完全代替了应用程序的登录入口功能,下面是很简单的JSP表单输入代码:

Index.jsp

<%@ page contentType="text/html;charset=UTF-8" language="java" %>

<html>

  <head><title>Simple jsp page</title></head>

  <body>

    <form action="/login.do" method="post">

        user:<input type="text" name="username"/>

        password:<input type="password" name="password"/>

        <input type="submit"/>

    </form>

  </body>

</html>

下面是web.xml配置文件代码:

<?xml version="1.0" encoding="UTF-8"?>

 

<!DOCTYPE web-app PUBLIC "-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN" "http://java./dtd/web-app_2_3.dtd">

<web-app>

 

    <servlet>

        <servlet-name>loginServlet</servlet-name>

        <servlet-class>com.lemon.jaas.web.LoginServlet</servlet-class>

    </servlet>

 

    <servlet-mapping>

        <servlet-name>loginServlet</servlet-name>

        <url-pattern>/login.do</url-pattern>

    </servlet-mapping>

 

    <welcome-file-list>

        <welcome-file>index.jsp</welcome-file>

    </welcome-file-list>

 

因为Web端所有响应客户端的工作都是由servlet以及jsp完成得,所以PassiveCallbackHandler代替了ConsoleCallbackHandler 直接传入构造函数的参数包含了用户名和密码,取消了和客户端交互的动作。因此它可以在Callback对象中设定正确的值。下面是PassiveCallbackHandler类的代码:

        public class PassiveCallbackHandler implements CallbackHandler {

    private String userName; // external login info input handler  , like password input and some device input

    private String password;

 

    public PassiveCallbackHandler(String userName, String password) {

        this.userName = userName;

        this.password = password;

    }

 

    public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException { //do some

        for (int i = 0; i < callbacks.length; i++) {

            if (callbacks[i] instanceof NameCallback) {

                NameCallback nc = (NameCallback) callbacks[i];

 

                nc.setName(this.userName);

            } else if (callbacks[i] instanceof PasswordCallback) {

                PasswordCallback pc = (PasswordCallback) callbacks[i];

 

                pc.setPassword(this.password.toCharArray());

            } else {

                throw(new UnsupportedCallbackException(callbacks[i], "Callback handler not support"));

            }

        }

    }

}

 

RdbmsLoginModule无需改动。

以上就完成了整个Web程序的调用JAAS的机制。

 

 

1.3       Tomcat服务器JAAS配置方法

  和在应用程序运行JAAS不同的是,配置JAAS方法会有很不一样。我们使用的是Tomcat 5.0.x 应用服务器,它的JAAS配置方法有数种,分别是

u       JAASRealm

u       JDBCRealm

u       DataSourceRealm

u       JNDIRealm

u       MemoryRealm

JAASRealm

 

1.         把自定义 LoginModuleUserRole等相关类放入Tomcat classpath

2.         把自定义login.config JAAS配置文件配置进JVM环境,例如:       JAVA_OPTS=-DJAVA_OPTS=-Djava.security.auth.login.config==$CATALINA_HOME/conf/jaas.config

3.         设置 web.xml里的 security-constraints 标签设定需要保护的资源

4.         $CATALINA_HOME/conf/server.xmlengine标签里设置JAASRealm标签

以下是JAASRealm标签的详细说明

属性

描述

className

只需要指定 org.apache.catalina.realm.JAASRealm

debug

设置debug级别,默认为不设置0

appName

JAAS配置文件应用名

userClassNames

自定义user Principals

roleClassNames

自定义 role Principals

useContextClassLoader

默认为true, 为了向后兼容类装载方式,使用Tomcat4以上版本ContextLoader装载方式

 

以下是完整的tomcat服务器配置例子:

%TOMCAT_HOME%/config/server.xml 中添加以下段落

<Realm className="org.apache.catalina.realm.JAASRealm"            

                appName="MyFooRealm"      

    userClassNames="org.foobar.realm.FooUser"      

     roleClassNames="org.foobar.realm.FooRole"

                      debug="99"/>

注意Realm标签所在位置将会使JAAS作用域不同

父标签

作用

<Engine>

Tomcat下面所有的web application 以及所有的host

<Host>

作用于该虚拟主机上的所有web application

<Context>

仅作用于当前web application

 

 

 

 

特别感谢:

本文是在众多前人的文章基础上改编并且添加内容而来,本文的版权属于hk2000c以及以下列出的所有参考文档作者以及相关译者,仅此感谢JAVA先驱者们的不懈努力与研究工作。如果您对本文有任何意见请在我的技术Blog http://blog.csdn.net/hk2000c 中和我联络,谢谢。

 

参考资料:

JAAS:灵活的Java安全机制》 John Musser/Paul Feuer 冯睿编译

《扩展JAAS实现类实例级授权》Carlos A. Fonseca, 软件工程师, IBM http://www-128.ibm.com/developerworks/cn/java/j-jaas/

《用 JAAS JSSE 实现 Java 安全性》Kyle Gabhart, 顾问, Gabhart Communications

 

 

本文没有对如何在Web Container 中具体实施JAAS作进一步阐述,这部分将会在中篇整理刊出。



Trackback: http://tb.blog.csdn.net/TrackBack.aspx?PostId=633091

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多