JAVA海洋 发表于 2013-1-28 19:29:56

编写自己的登录与访问控制模块

安全性是Java鼓吹得最多的特性之一,的确,Java的安全特性涵盖了从应用级别到语言级别乃至JVM本身。以前大家都知道有个Sandbox,但仅有Sandbox尚不能满足,或者说不能很方便地做到我们所需要的全部安全需求,譬如现在一个系统首先起码需要一个登录功能,更进一步的话,还需要对用户访问资源的行为进行约束,下面我想大致讲一下Java是怎样做这些事情的,基本上是一个总结或者说是“读后感”的性质,同时给出一个简单的实现例子,这个例子其实还是模仿人家的,呵呵……

1.Java的访问控制机制


谈到访问控制,或者说“授权”,这里有两层含义,一是从资源的角度,这个socket端口是否被允许操作?这个文件是可读的?可写的?还是可执行的?还是以上都行?这就是我们在UNIX下用“ls?l”命令列出当前目录下文件时,那些“-rwx-”之类的含义;二是从访问者的角度,我想通过80端口看Web上新浪欧洲杯的新闻,在这个系统中有没有这个资格?我想播放D盘上一个名为“friends.rm”的视频文件,我得到了访问这个文件的权限了吗?我有运行播放器的权限吗?
Java在访问控制策略上同时考虑了这两方面内容,你说“不对呀,我用FileOutputStream写文件,用Socket类连接远程主机都用得好好的,没什么限制呀”,这我们得先谈谈什么叫做“安全管理器”(SecurityManger)。安全管理器从JDK1.0就开始有了,多古老啊!Java从设计的那一天开始就考虑了安全因素,安全管理器是Sandbox的最重要的一个部分,也是访问控制的总协调者,我们能够在通常情况下正常使用网络和文件,那是因为当启动application的时候(注意是application,不是applet!),如果你不加“-Djava.security.manager”选项,JVM是不会启动Sandbox的,这时你可以“为所欲为”,而不会碰到SecurityException之类的异常;一旦加入了“-Djava.security.manager”选项,你就会发现有一连串的异常出现喽!

Exceptioninthread"main"java.security.AccessControlException:accessdenied(……)
……


Java内置了一个默认的安全策略,这种情况下安全管理器首先装载的是这个默认的策略,不信啊,不信你检查一下你的“%JAVA_HOME%\jre\lib\security\”目录,是不是有个叫“java.policy”的文件?用notepad打开看看:

//Standardextensionsgetallpermissionsbydefault

grantcodeBase"file:${java.home}/lib/ext/*"{
permissionjava.security.AllPermission;
};

//defaultpermissionsgrantedtoalldomains

grant{
//Allowsanythreadtostopitselfusingthejava.lang.Thread.stop()
//methodthattakesnoargument.
//Notethatthispermissionisgrantedbydefaultonlytoremain
//backwardscompatible.
//Itisstronglyrecommendedthatyoueitherremovethispermission
//fromthispolicyfileorfurtherrestrictittocodesources
//thatyouspecify,becauseThread.stop()ispotentiallyunsafe.
//See"http://java.sun.com/notes"formoreinformation.
permissionjava.lang.RuntimePermission"stopThread";

//allowsanyonetolistenonun-privilegedports
permissionjava.net.SocketPermission"localhost:1024-","listen";

//"standard"properiesthatcanbereadbyanyone

permissionjava.util.PropertyPermission"java.version","read";
permissionjava.util.PropertyPermission"java.vendor","read";
permissionjava.util.PropertyPermission"java.vendor.url","read";
permissionjava.util.PropertyPermission"java.class.version","read";
permissionjava.util.PropertyPermission"os.name","read";
permissionjava.util.PropertyPermission"os.version","read";
permissionjava.util.PropertyPermission"os.arch","read";
permissionjava.util.PropertyPermission"file.separator","read";
permissionjava.util.PropertyPermission"path.separator","read";
permissionjava.util.PropertyPermission"line.separator","read";

permissionjava.util.PropertyPermission"java.specification.version","read";
permissionjava.util.PropertyPermission"java.specification.vendor","read";
permissionjava.util.PropertyPermission"java.specification.name","read";

permissionjava.util.PropertyPermission"java.vm.specification.version","read";
permissionjava.util.PropertyPermission"java.vm.specification.vendor","read";
permissionjava.util.PropertyPermission"java.vm.specification.name","read";
permissionjava.util.PropertyPermission"java.vm.version","read";
permissionjava.util.PropertyPermission"java.vm.vendor","read";
permissionjava.util.PropertyPermission"java.vm.name","read";
};


可以看到,JVM给沙箱内的application分配的权限仅限于中止线程,监听1024以上的TCP端口,以及对一些系统属性的读取权限,像一般的socket操作和文件操作的权限都没有。
了解了安全管理器的概念以后我们回到授权问题上来。对用户来说,最担心的莫过于机器中病毒,病毒本质上是一种恶意的程序,所以访问控制首先是要对代码的权限进行控制,上面我一直都在谈Sandbox,也就是所谓的“沙箱”,熟悉Java安全性发展历史的朋友大概对它不会陌生,初期的Java是采用这样一种安全策略,即:本地代码是可信的,而远程代码是不可信的,譬如applet是一种从网络上下载到本地并在浏览器上运行的一段远程代码,因而是不可信的,所以早期的applet被完全置于Sandbox当中,得到的权限是非常有限的;在1.0以后,直至Java2出现之前,安全策略作了一些灵活的改变,applet不再是完全被歧视的“二等公民”了,因为有了签名applet,用户可以选择信任这种经过签名的applet,从而applet也可以做一些以前被认为是“出格”的事情;到了Java2,情况又变了,以前一向被信任的本地代码似乎也变得不是那么可靠了,这还真说不准,难保谁不会在你出去跟女朋友逛街的时候,偷偷溜进来在你机器上拷个病毒什么的^_^,这样本地代码就落到了和远程代码相等同的地位了,这是比较符合现实世界场景的,在Java2中的安全策略被称之为“可配置的安全策略”,任何代码,只要是通过安全管理器访问,就必须为它预先设定好访问权限,在这个之外的资源还是别的什么东东,对不起,

java.security.AccessControlException:accessdenied……

此路不通!
简单总结一下Java安全模型的发展史,大概就是下面的几幅图了:
http://www.agoit.com/images/blog_csdn_net/jungleford/36125/o_security1.0.gif

http://www.agoit.com/images/blog_csdn_net/jungleford/36125/o_security1.1.gif

http://www.agoit.com/images/blog_csdn_net/jungleford/36125/o_security1.2.gif

由于现在普遍是多用户的系统,所以在实现代码级访问控制之外,我们还希望能够对用户的行为进行约束,因为对系统造成破坏的因素不仅仅是恶意代码,人自身的有意或无意的不当操作也会危及系统,譬如向上面说的你不在的时候别人可以在你机器上拷病毒,如果系统能在你不在的时候也能拒绝这个家伙的登录企图,那样麻烦岂不是少很多?于是在Java安全核心之外,提供了一个名为“Java认证与授权服务”(JavaAuthenticationandAuthorizationServices,JAAS)东东,专门用来处理对用户的认证和授权,这也就是所谓的“以用户为中心的授权模型”,说白了就是在“以代码为中心的授权模型”上再加一层,首先用户要获得访问权限,然后用户去操纵代码,代码来实行真正的访问操作。下面我主要是讲讲JAAS是如何工作的。



2.了解几个主要的API



JAAS的API基本上位于javax.security.auth包及其下属子包中,很容易找到的。

javax.security.auth.Subject
Subject表征系统中一个认证的用户,这个词时而被译为“主题”时而被以为“主体”(下面我要谈到的Principal有时候也被译为“主体”),不管它有几个马甲,反正你就可以看成是在Java中你这个人的影子,你对系统的访问就体现为Subject.doAs()或Subject.doAsPrivileged()方法。

java.security.Principal
Principal代表用户的一种身份对象,一个用户的身份可能不只一个,他所在的组或所担任的角色也是一种身份,“张翠山”可以说“铁划银钩”,可以说“张三丰的徒弟”,可以说“张无忌他老爹”,我说“武当七侠”甚至“武当派”,当然也没错,这是一个组,呵呵。通过一次登录后,可能向Subject插入一个或多个Principal,这时候Subject才有实际意义,而不是一个空壳。

javax.security.auth.login.LoginContext
LoginContext旨在提供一个开放的登录总接口,你只需要用从策略文件中取得的策略名,以及下面介绍的回调对象创建得到一个LoginContext,再调用一次login()方法即可完成登录,登录模块在这里是透明的。

javax.security.auth.spi.LoginModule
登录模块实现了对用户的认证逻辑,它的作用是在登录配置文件中得到体现,在后面的例子里我们会看到怎么编写一个登录配置文件以及上面说过的策略文件。LoginModule接口包括五个主要的方法:

initialize方法,初始化模块,保存当前Subject以及一些参数。

login方法,判断一次登录过程中是否认证通过。

commit方法,是否提交登录结果。咦,login不就行了吗?干吗要来个提交呢?这是因为JAAS采用的是类似于数据库事务处理的过程,将整体登录分为两阶段,尽管你login成功,但系统仍有权力根据你这次login的“地位”来决定究竟要不要接纳你的身份,只有通过commit,用户的Principal才会被真正添加到Subject当中,哼哼,真阴险!这里所说的login的“地位”是指策略文件中登录模块的“控制标记”选项,有点类似于优先级的概念,因为登录一个系统的过程可能会经过不止一个登录模块,譬如我们登录系统输入口令,但这个口令可能保存在一个数据库或LDAP目录中,访问这个数据源也需要经过认证,这就不止一个登录模块了吧?所以我们需要分清哪些认证过程是重要的,哪些又是次要的,系统对用户身份的接收与否是对这些策略综合权衡的结果。

abort方法:哎呀,上面解释得是不是太多了?我们再看看abort,还记得数据库事务处理的回退过程(rollback)吗?abort就有点像rollback,表示系统并不接受你的身份,以前做过的统统作废,现场又恢复到和登录前完全一样。

logout方法:注销过程,清除内部状态,并删除Subject中全部的Principal。

javax.security.auth.callback.CallbackHandler
回调对象是JAAS中用以将交互过程和认证逻辑分离的一种机制,这也是符合OO和松散耦合(looselycoupled是一个时髦词汇^_^)精神的。JAAS已经实现了一些常用的回调对象,包括取得用户名的NameCallback,取得口令的PasswordCallback,从终端获得输入文本的TextInputCallback,向终端发出文本消息的TextOutputCallback等等。我们所要做的仅仅是实现一个CallbackHandler接口,根据不同的交互信息类型,把从终端得到的信息填到相应的Callback中去就行了。后面的例子我是用了一个JoptionPane提示文本框来输入用户名和口令的。

java.security.PrivilegedAction
上面说了那么多登录相关的接口,该说说授权了,如果我们只谈写源代码,那么很简单,只要实现一个PrivilegedAction接口,覆盖一个run()方法,把你想要做的事情统统放到这个run中就可以了。但我说的只是写源代码部分,授权方面用得较多的还是在管理方面,譬如如何编写一个策略文件,下面我们就来看看JAAS登录和访问控制的一个完整流程。

3.基本流程



JAAS被称为是“可插拔的认证框架”(PluggableAuthenticationModule,PAMs),其实PAM也不是SUN的专利,Linux上就有这方面的实现,但PAM确实是较早用在了Solaris系统上。我们看看JAAS在认证和授权方面是怎么体现PAM思想的:

主要包括这么几个部分:
用户的Principal(MyPrincipal.class)
登录模块(MyLoginModule.class)
回调对象(MyCallbackHandler.class)
访问代码(MyAction.class)
系统入口(JAASTest.class)
资源(myfile.txt)
策略配置文件(login.conf)
登录配置文件(jaas.policy)
启动脚本(JAASTest.bat)

由于启动java的选项太长,所以写了一个shell,在控制台下运行JAASTest.bat,选项“-Djava.security.manager”指定启用安全管理器,执行的是JAASTest类的main线程,由于shell指定选项“-Djava.security.policy=jaas.policy”,该策略文件允许当前代码创建LoginContext,并授权进行其它一些操作,它首先初始化一个LoginContext,选项“-Djava.security.auth.login.config=login.conf”指定了登录配置文件,所以在当前目录下找到文件login.conf,该文件中指定的登录策略名称为“JAASTest”,所以在LoginCotext中第一个参数也是“JAASTest”,同时使用我们自定义的回调对象MyCallbackHandler。创建LoginContext成功,可以进行登录了,调用LoginContext的login方法,该方法找到login.conf中的登录模块MyLoginModule(当然可以有若干个登录模块,这里我只用了一个),执行该模块的登录过程,MyLoginModule首先初始化:

Loginmoduleinitializing...


并使用LoginContext所赋予它的回调对象MyCallbackHandler,该回调过程弹出两个图形对话框,要求输入用户名和口令,
http://www.agoit.com/images/blog_csdn_net/jungleford/36125/o_user.jpg

http://www.agoit.com/images/blog_csdn_net/jungleford/36125/o_password.jpg

我们使用指定的用户名“user”和口令“letmepass”,确定以后分别传给当前的NameCallback和PasswordCallback,然后回到MyLoginModule的login过程,该过程从回调对象处得到NameCallback和PasswordCallback,进行认证(这里仅仅是简单的用户名和口令的对比),

MyLoginModule:Authenticationpass!


并决定是否commit,由于在login.conf中定义该登录模块是required,所以是一个必须通过才能整体认证成功的模块。

MyLoginModule:Addanewprincipaltocurrentsubject.



如果整体得到认证通过,那么Subject就可以授权允许MyAction中的代码了,如语句Subject.doAs(…)所示,该代码的动作是读取当前目录下的myfile.txt文件,并将其内容打印到控制台,注意到在策略文件jaas.policy中赋予MyPrincipal身份对myfile.txt的读取权限,所以我们成功看到控制台下出现

Accesssuccessfully!Readingfile:
==================================
Why?
Becausetheycare!
Becausetheywanttoknowthetruth!
Becausetheywanttheircountryback!
Becauseitstillbelongstousaslongasthepeoplehavethegutstofightforwhattheybelievein!

==================================


这是我喜欢的一部经典影片“JFK”中检察官Garrison激情的最后陈词中的一段,呵呵!
以上过程我们可以用个图表来表示:
http://www.agoit.com/images/blog_csdn_net/jungleford/36125/o_jaas.gif

4.简单的例子



以上流程中使用到的Java源代码和配置文件如下:

//MyPrincipal.java
packagecom.jungleford.auth;

importjava.security.Principal;

publicclassMyPrincipalimplementsPrincipal
{//一个Principal的例子
privateStringname;//Principal的名字

publicMyPrincipal(Stringname)
{
this.name=name;
}

publicStringgetName()
{//取得Principal的名字
returnthis.name;
}

publicbooleanequals(Objectprincipal)
{//判断两个Pincipal相同的依据
if(principalinstanceofMyPrincipal)
returnthis.name.equals(((MyPrincipal)principal).getName());
else
returnfalse;
}

publicStringtoString()
{//Principal的表示
return"MyPrincipal:"+this.name;
}

publicinthashCode()
{//确定本对象的散列值
//用于有基于散列容器的场合,判断在散列容器中是否是同一个对象。
//如果对hashCode感兴趣,请参见:
//http://www-900.ibm.com/developerWorks/cn/java/j-jtp05273/
returnthis.name.hashCode();
}
}



//MyLoginModule.java
packagecom.jungleford.auth;

importjava.util.*;
importjava.io.IOException;
importjava.security.Principal;
importjavax.security.auth.*;
importjavax.security.auth.callback.*;
importjavax.security.auth.login.*;
importjavax.security.auth.spi.*;

publicclassMyLoginModuleimplementsLoginModule
{//一个登录模块的例子
privateSubjectsubject;//登录主体的表征
privateCallbackHandlercbHandler;//回调对象,提供终端下获取用户名、口令的界面
privateMapsharedState;//用于缓存中间结果的共享区
privateMapoptions;//用于保存某些登录模块所需要用到的一些配置选项

privatebooleansucceeded=false;//一次login成功的标志
privatebooleancmtSucceeded=false;//整体登录成功的提交标志

privateStringusername;//取得用户名
privatechar[]password;//取得口令

privatePrincipalprincipal;//取得登录后的身份标志

publicvoidinitialize(Subjectsubject,
CallbackHandlercbHandler,
MapsharedState,
Mapoptions)
{//初始化过程
System.out.println("Loginmoduleinitializing...");
System.out.println();
this.subject=subject;
this.cbHandler=cbHandler;
this.sharedState=sharedState;
this.options=options;
}

publicbooleanlogin()throwsLoginException
{//一次登录过程
if(cbHandler==null)//尚未配置回调对象
thrownewLoginException("Error:NoCallbackHandleravailable"+
"togarnerauthenticationinformationfromtheuser");

Callback[]cbs=newCallback;//仅使用用户名回调和口令回调
cbs=newNameCallback("Login:");
cbs=newPasswordCallback("Password:",false);

try
{
cbHandler.handle(cbs);
username=((NameCallback)cbs).getName();
char[]temp=((PasswordCallback)cbs).getPassword();
if(temp==null)
{//口令为空
temp=newchar;
}
password=newchar;
System.arraycopy(temp,0,password,0,temp.length);
((PasswordCallback)cbs).clearPassword();//清除内存中的口令痕迹
}
catch(IOExceptionioe)
{
thrownewLoginException(ioe.toString());
}
catch(UnsupportedCallbackExceptionuce)
{
thrownewLoginException("Error:"+uce.getCallback().toString()+
"notavailabletogarnerauthenticationinformation"+
"fromtheuser");
}

booleanusrCorrect=false;//用户名正确否?
booleanpwdCorrect=false;//口令正确否?
if(username.equals("user"))//目前仅允许用户名为user的登录
usrCorrect=true;
if(usrCorrect&&
password.length==9&&
password=='l'&&
password=='e'&&
password=='t'&&
password=='m'&&
password=='e'&&
password=='p'&&
password=='a'&&
password=='s'&&
password=='s')//user的口令指定为letmepass
{
System.out.println("MyLoginModule:Authenticationpass!");
System.out.println();
pwdCorrect=true;
succeeded=true;
returntrue;//一次登录成功
}
else
{
System.out.println("MyLoginModule:Authenticationfailed!");
System.out.println();
succeeded=false;
username=null;
for(inti=0;i<password.length;i++)//清除内存中的口令痕迹
password='';
password=null;
if(!usrCorrect)
{
thrownewFailedLoginException("Usernameincorrect!");
}
else
{
thrownewFailedLoginException("Passwordincorrect!");
}
}
}

publicbooleancommit()throwsLoginException
{//根据登录配置策略判断是否整体登录成功
if(succeeded==false)
{
returnfalse;
}
else
{
principal=newMyPrincipal(username);
if(!subject.getPrincipals().contains(principal))
subject.getPrincipals().add(principal);//把新的身份添加到subject中

System.out.println("MyLoginModule:Addanewprincipaltocurrentsubject.");
System.out.println();

username=null;
for(inti=0;i<password.length;i++)//清除内存中的口令痕迹
password='';
password=null;

cmtSucceeded=true;
returntrue;
}
}

publicbooleanabort()throwsLoginException
{//放弃登录,将状态复位至登录前
if(succeeded==false)
{
returnfalse;
}
elseif(succeeded==true&&cmtSucceeded==false)
{
succeeded=false;
username=null;
if(password!=null)
{
for(inti=0;i<password.length;i++)//清除内存中的口令痕迹
password='';
password=null;
}
principal=null;
}
else
{
logout();
}
returntrue;
}

publicbooleanlogout()throwsLoginException
{//注销,并将状态复位至登录前
subject.getPrincipals().remove(principal);
succeeded=false;
succeeded=cmtSucceeded;
username=null;
if(password!=null)
{
for(inti=0;i<password.length;i++)//清除内存中的口令痕迹
password='';
password=null;
}
principal=null;
returntrue;
}
}




//MyCallbackHandler.java
packagecom.jungleford.auth;

importjava.io.IOException;
importjavax.security.auth.callback.*;
importjavax.swing.*;
importjava.awt.*;
importjava.awt.event.*;

publicclassMyCallbackHandlerimplementsCallbackHandler
{
publicvoidhandle(Callback[]cbs)
throwsIOException,UnsupportedCallbackException
{
Stringusername=
JOptionPane.showInputDialog(null,
"<html>Availablename:"+
"<fontcolor=\"blue\">user</font></html>",
"Enteryourname",
JOptionPane.QUESTION_MESSAGE);
Stringpassword=
JOptionPane.showInputDialog(null,
"<html>Availablepassword:"+
"<fontcolor=\"blue\">letmepass</font></html>",
"Enteryourpassword",
JOptionPane.QUESTION_MESSAGE);
for(inti=0;i<cbs.length;i++)
{
if(cbsinstanceofTextOutputCallback)
{
TextOutputCallbacktoc=(TextOutputCallback)cbs;
switch(toc.getMessageType())
{
caseTextOutputCallback.INFORMATION:
System.out.println(toc.getMessage());
break;
caseTextOutputCallback.ERROR:
System.out.println("Error:"+toc.getMessage());
break;
caseTextOutputCallback.WARNING:
System.out.println("Warning:"+toc.getMessage());
break;
default:
thrownewIOException("Unsupportedmessagetype:"+
toc.getMessageType());
}
}
elseif(cbsinstanceofNameCallback)
{
//prompttheuserforausername
NameCallbacknc=(NameCallback)cbs;
//System.err.print(nc.getPrompt());
//System.err.flush();
nc.setName(username);
}
elseif(cbsinstanceofPasswordCallback)
{
//prompttheuserforsensitiveinformation
PasswordCallbackpc=(PasswordCallback)cbs;
//System.err.print(pc.getPrompt());
//System.err.flush();
pc.setPassword(password.toCharArray());
}
else
{
thrownewUnsupportedCallbackException(cbs,"UnrecognizedCallback");
}
}
}
}




//MyAction.java
packagecom.jungleford.auth;

importjava.io.*;
importjava.security.*;

publicclassMyActionimplementsPrivilegedAction
{//对资源的授权访问动作
publicObjectrun()
{//run方法是必须overriding的
//这里我们假设访问动作是读取当前目录下myfile.txt文件的内容
Filefile=newFile("myfile.txt");
Stringcontent="";
try
{
BufferedReaderreader=
newBufferedReader(
newFileReader(file));
Stringline=reader.readLine();
while(line!=null)
{
content+=line+"\n";
line=reader.readLine();
}
}
catch(Exceptione)
{
System.err.println("Error:Readingfilefailed!");
System.err.println();
e.printStackTrace();
}
returncontent;
}
}




//JAASTest.java
packagecom.jungleford.auth;

importjavax.security.auth.Subject;
importjavax.security.auth.login.LoginContext;

publicclassJAASTest
{//测试我们JAAS登录和授权的shell
publicstaticvoidmain(String[]args)
{
LoginContextlc=null;
try
{
//创建context,使用自定义的回调对象,策略名为JAASTest
//简单起见,仅使用一个MyLoginModule模块
lc=newLoginContext("JAASTest",newMyCallbackHandler());
}
catch(Exceptione)
{
System.err.println("Error:Creatinglogincontextfailed!");
System.err.println();
e.printStackTrace();
System.exit(-1);
}
try
{//整体登录
lc.login();
}
catch(Exceptione)
{
System.err.println("Error:Loginfailed!");
System.err.println();
e.printStackTrace();
System.exit(-1);
}
//获得授权访问
Objectobject=Subject.doAs(lc.getSubject(),newMyAction());
System.out.println("Accesssuccessfully!Readingfile:");
System.out.println("==================================");
System.out.println(object);
System.out.println("==================================");
System.exit(0);
}
}



//login.conf
JAASTest
{
com.jungleford.auth.MyLoginModulerequired;
};



//jaas.policy
grant
{
permissionjavax.security.auth.AuthPermission"createLoginContext";
permissionjavax.security.auth.AuthPermission"doAs";
permissionjavax.security.auth.AuthPermission"modifyPrincipals";
permissionjavax.security.auth.AuthPermission"getSubject";
};

grantprincipalcom.jungleford.auth.MyPrincipal"guest"
{
permissionjava.io.FilePermission"myfile.txt","read";
};



//JAASTest.bat
java-Djava.security.manager
-Djava.security.auth.login.config=login.conf
-Djava.security.policy=jaas.policy
com.jungleford.auth.JAASTest



//myfile.txt
Why?
Becausetheycare!
Becausetheywanttoknowthetruth!
Becausetheywanttheircountryback!
Becauseitstillbelongstousaslongasthepeoplehavethegutstofightforwhattheybelievein!



参考资料



JavaSecurityArchitecture

Java授权内幕

Java安全性第二部分认证与授权

JavaSecurity,2ndEdition,byScottOaks

J2EESecurity,byPankajKumar
页: [1]
查看完整版本: 编写自己的登录与访问控制模块