安全性是 Java 应用程序的非功能性需求的重要组成部分,如同其它的非功能性需求一样,安全性很容易被开发人员所忽略。当然,对于 Java EE 的开发人员来说,安全性的话题可能没那么陌生,用户认证和授权可能是绝大部分 Web 应用都有的功能。类似 Spring Security 这样的框架,也使得开发变得更加简单。本文并不会讨论 Web 应用的安全性,而是介绍 Java 安全一些底层和基本的内容。
认证
用户认证是应用安全性的重要组成部分,其目的是确保应用的使用者具有合法的身份。 Java 安全中使用术语主体( Subject )来表示访问请求的来源。一个主体可以是任何的实体。一个主体可以有多个不同的身份标识( Principal )。比如一个应用的用户这类主体,就可以有用户名、身份证号码和手机号码等多种身份标识。除了身份标识之外,一个主体还可以有公开或是私有的安全相关的凭证( Credential ),包括密码和密钥等。
典型的用户认证过程是通过登录操作来完成的。在登录成功之后,一个主体中就具备了相应的身份标识。Java 提供了一个可扩展的登录框架,使得应用开发人员可以很容易的定制和扩展与登录相关的逻辑。登录的过程由 LoginContext 启动。在创建 LoginContext 的时候需要指定一个登录配置( Configuration )的名称。该登录配置中包含了登录所需的多个 LoginModule 的信息。每个 LoginModule 实现了一种登录方式。当调用 LoginContext 的 login 方法的时候,所配置的每个 LoginModule 会被调用来执行登录操作。如果整个登录过程成功,则通过 getSubject 方法就可以获取到包含了身份标识信息的主体。开发人员可以实现自己的 LoginModule 来定制不同的登录逻辑。
每个 LoginModule 的登录方式由两个阶段组成。第一个阶段是在 login 方法的实现中。这个阶段用来进行必要的身份认证,可能需要获取用户的输入,以及通过数据库、网络操作或其它方式来完成认证。当认证成功之后,把必要的信息保存起来。如果认证失败,则抛出相关的异常。第二阶段是在 commit 或 abort 方法中。由于一个登录过程可能涉及到多个 LoginModule。LoginContext 会根据每个 LoginModule 的认证结果以及相关的配置信息来确定本次登录是否成功。LoginContext 用来判断的依据是每个 LoginModule 对整个登录过程的必要性,分成必需、必要、充分和可选这四种情况。如果登录成功,则每个 LoginModule 的 commit 方法会被调用,用来把身份标识关联到主体上。如果登录失败,则 LoginModule 的 abort 方法会被调用,用来清除之前保存的认证相关信息。
在 LoginModule 进行认证的过程中,如果需要获取用户的输入,可以通过 CallbackHandler 和对应的 Callback 来完成。每个 Callback 可以用来进行必要的数据传递。典型的启动登录的过程如下:
public Subject login() throws LoginException { TextInputCallbackHandler callbackHandler = new TextInputCallbackHandler(); LoginContext lc = new LoginContext("SmsApp", callbackHandler); lc.login(); return lc.getSubject(); }
这里的 SmsApp 是登录配置的名称,可以在配置文件中找到。该配置文件的内容也很简单。
SmsApp { security.login.SmsLoginModule required; };
这里声明了使用 security.login.SmsLoginModule 这个登录模块,而且该模块是必需的。配置文件可以通过启动程序时的参数 java.security.auth.login.config 来指定,或修改 JVM 的默认设置。下面看看 SmsLoginModule 的核心方法 login 和 commit。
public boolean login() throws LoginException { TextInputCallback phoneInputCallback = new TextInputCallback("Phone number: "); TextInputCallback smsInputCallback = new TextInputCallback("Code: "); try { handler.handle(new Callback[] {phoneInputCallback, smsInputCallback}); } catch (Exception e) { throw new LoginException(e.getMessage()); } String code = smsInputCallback.getText(); boolean isValid = code.length() > 3; // 此处只是简单的进行验证。 if (isValid) { phoneNumber = phoneInputCallback.getText(); } return isValid; } public boolean commit() throws LoginException { if (phoneNumber != null) { subject.getPrincipals().add(new PhonePrincipal(phoneNumber)); return true; } return false; }
这里使用了两个 TextInputCallback 来获取用户的输入。当用户输入的编码有效的时候,就把相关的信息记录下来,此处是用户的手机号码。在 commit 方法中,就把该手机号码作为用户的身份标识与主体关联起来。
权限控制
在验证了访问请求来源的合法身份之后,另一项工作是验证其是否具有相应的权限。权限由 Permission 及其子类来表示。每个权限都有一个名称,该名称的含义与权限类型相关。某些权限有与之对应的动作列表。比较典型的是文件操作权限 FilePermission ,它的名称是文件的路径,而它的动作列表则包括读取、写入和执行等。Permission 类中最重要的是 implies 方法,它定义了权限之间的包含关系,是进行验证的基础。
权限控制包括管理和验证两个部分。管理指的是定义应用中的权限控制策略,而验证指的则是在运行时刻根据策略来判断某次请求是否合法。策略可以与主体关联,也可以没有关联。策略由 Policy 来表示,JDK 提供了基于文件存储的基本实现。开发人员也可以提供自己的实现。在应用运行过程中,只可能有一个 Policy 处于生效的状态。验证部分的具体执行者是 AccessController ,其中的 checkPermission 方法用来验证给定的权限是否被允许。在应用中执行相关的访问请求之前,都需要调用 checkPermission 方法来进行验证。如果验证失败的话,该方法会抛出 AccessControlException 异常。 JVM 中内置提供了一些对访问关键部分内容的访问控制检查,不过只有在启动应用的时通过参数 -Djava.security.manager 启用了安全管理器之后才能生效,并与策略相配合。
与访问控制相关的另外一个概念是特权动作。特权动作只关心动作本身所要求的权限是否具备,而并不关心调用者是谁。比如一个写入文件的特权动作,它只要求对该文件有写入权限即可,并不关心是谁要求它执行这样的动作。特权动作根据是否抛出受检异常,分为 PrivilegedAction 和 PrivilegedExceptionAction 。这两个接口都只有一个 run 方法用来执行相关的动作,也可以向调用者返回结果。通过 AccessController 的 doPrivileged 方法就可以执行特权动作。
Java 安全使用了保护域的概念。每个保护域都包含一组类、身份标识和权限,其意义是在当访问请求的来源是这些身份标识的时候,这些类的实例就自动具有给定的这些权限。保护域的权限既可以是固定,也可以根据策略来动态变化。 ProtectionDomain 类用来表示保护域,它的两个构造方法分别用来支持静态和动态的权限。一般来说,应用程序通常会涉及到系统保护域和应用保护域。不少的方法调用可能会跨越多个保护域的边界。因此,在 AccessController 进行访问控制验证的时候,需要考虑当前操作的调用上下文,主要指的是方法调用栈上不同方法所属于的不同保护域。这个调用上下文一般是与当前线程绑定在一起的。通过 AccessController 的 getContext 方法可以获取到表示调用上下文的 AccessControlContext 对象,相当于访问控制验证所需的调用栈的一个快照。在有些情况下,会需要传递此对象以方便在其它线程中进行访问控制验证。
考虑下面的权限验证代码:
Subject subject = new Subject(); ViewerPrincipal principal = new ViewerPrincipal("Alex"); subject.getPrincipals().add(principal); Subject.doAsPrivileged(subject, new PrivilegedAction<Object>() { public Object run() { new Viewer().view(); return null; } }, null);
这里创建了一个新的 Subject 对象并关联上身份标识。通常来说,这个过程是由登录操作来完成的。通过 Subject 的 doAsPrivileged 方法就可以执行一个特权动作。Viewer 对象的 view 方法会使用 AccessController 来检查是否具有相应的权限。策略配置文件的内容也比较简单,在启动程序的时候通过参数 java.security.auth.policy 指定文件路径即可。
grant Principal security.access.ViewerPrincipal "Alex" { permission security.access.ViewPermission "CONFIDENTIAL"; }; // 这里把名称为 CONFIDENTIAL 的 ViewPermission 授权给了身份标识为 Alex 的主体。
加密、解密与签名
构建安全的 Java 应用离不开加密和解密。Java 的密码框架采用了常见的服务提供者架构,以提供所需的可扩展性和互操作性。该密码框架提供了一系列常用的服务,包括加密、数字签名和报文摘要等。这些服务都有服务提供者接口( SPI ),服务的实现者只需要实现这些接口,并注册到密码框架中即可。比如加密服务 Cipher 的 SPI 接口就是 CipherSpi 。每个服务都可以有不同的算法来实现。密码框架也提供了相应的工厂方法用来获取到服务的实例。比如想使用采用 MD5 算法的报文摘要服务,只需要调用 MessageDigest.getInstance(“MD5”) 即可。
加密和解密过程中并不可少的就是密钥( Key )。加密算法一般分成对称和非对称两种。对称加密算法使用同一个密钥进行加密和解密;而非对称加密算法使用一对公钥和私钥,一个加密的时候,另外一个就用来解密。不同的加密算法,有不同的密钥。对称加密算法使用的是 SecretKey ,而非对称加密算法则使用 PublicKey 和 PrivateKey 。与密钥 Key 对应的另一个接口是 KeySpec ,用来描述不同算法的密钥的具体内容。比如一个典型的使用对称加密的方式如下:
KeyGenerator generator = KeyGenerator.getInstance("DES"); SecretKey key = generator.generateKey(); saveFile("key.data", key.getEncoded()); Cipher cipher = Cipher.getInstance("DES"); cipher.init(Cipher.ENCRYPT_MODE, key); String text = "Hello World"; byte[] encrypted = cipher.doFinal(text.getBytes()); saveFile("encrypted.bin", encrypted);
加密的时候首先要生成一个密钥,再由 Cipher 服务来完成。可以把密钥的内容保存起来,方便传递给需要解密的程序。
byte[] keyData = getData("key.data"); SecretKeySpec keySpec = new SecretKeySpec(keyData, "DES"); Cipher cipher = Cipher.getInstance("DES"); cipher.init(Cipher.DECRYPT_MODE, keySpec); byte[] data = getData("encrypted.bin"); byte[] result = cipher.doFinal(data);
解密的时候先从保存的文件中得到密钥编码之后的内容,再通过 SecretKeySpec 获取到密钥本身的内容,再进行解密。
报文摘要的目的在于防止信息被有意或无意的修改。通过对原始数据应用某些算法,可以得到一个校验码。当收到数据之后,只需要应用同样的算法,再比较校验码是否一致,就可以判断数据是否被修改过。相对原始数据来说,校验码长度更小,更容易进行比较。消息认证码( Message Authentication Code )与报文摘要类似,不同的是计算的过程中加入了密钥,只有掌握了密钥的接收者才能验证数据的完整性。
使用公钥和私钥就可以实现数字签名的功能。某个发送者使用私钥对消息进行加密,接收者使用公钥进行解密。由于私钥只有发送者知道,当接收者使用公钥解密成功之后,就可以判定消息的来源肯定是特定的发送者。这就相当于发送者对消息进行了签名。数字签名由 Signature 服务提供,签名和验证的过程都比较直接。
Signature signature = Signature.getInstance("SHA1withDSA"); KeyPairGenerator keyGenerator = KeyPairGenerator.getInstance("DSA"); KeyPair keyPair = keyGenerator.generateKeyPair(); PrivateKey privateKey = keyPair.getPrivate(); signature.initSign(privateKey); byte[] data = "Hello World".getBytes(); signature.update(data); byte[] signatureData = signature.sign(); // 得到签名 PublicKey publicKey = keyPair.getPublic(); signature.initVerify(publicKey); signature.update(data); boolean result = signature.verify(signatureData); // 进行验证
验证数字签名使用的公钥可以通过文件或证书的方式来进行发布。
安全套接字连接
在各种数据传输方式中,网络传输目前使用较广,但是安全隐患也更多。安全套接字连接指的是对套接字连接进行加密。加密的时候可以选择对称加密算法。但是如何在发送者和接收者之间安全的共享密钥,是个很麻烦的问题。如果再用加密算法来加密密钥,则成为了一个循环问题。非对称加密算法则适合于这种情况。私钥自己保管,公钥则公开出去。发送数据的时候,用私钥加密,接收者用公开的公钥解密;接收数据的时候,则正好相反。这种做法解决了共享密钥的问题,但是另外的一个问题是如何确保接收者所得到的公钥确实来自所声明的发送者,而不是伪造的。为此,又引入了证书的概念。证书中包含了身份标识和对应的公钥。证书由用户所信任的机构签发,并用该机构的私钥来加密。在有些情况下,某个证书签发机构的真实性会需要由另外一个机构的证书来证明。通过这种证明关系,会形成一个证书的链条。而链条的根则是公认的值得信任的机构。只有当证书链条上的所有证书都被信任的时候,才能信任证书中所给出的公钥。
日常开发中比较常接触的就是 HTTPS ,即安全的 HTTP 连接。大部分用 Java 程序访问采用 HTTPS 网站时出现的错误都与证书链条相关。有些网站采用的不是由正规安全机构签发的证书,或是证书已经过期。如果必须访问这样的 HTTPS 网站的话,可以提供自己的套接字工厂和主机名验证类来绕过去。另外一种做法是通过 keytool 工具把证书导入到系统的信任证书库之中。
URL url = new URL("https://localhost:8443"); SSLContext context = SSLContext.getInstance("TLS"); context.init(new KeyManager[] {}, new TrustManager[] {new MyTrustManager()}, new SecureRandom());HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); connection.setSSLSocketFactory(context.getSocketFactory()); connection.setHostnameVerifier(new MyHostnameVerifier());
这里的 MyTrustManager 实现了 X509TrustManager 接口,但是所有方法都是默认实现。而 MyHostnameVerifier 实现了 HostnameVerifier 接口,其中的 verify 方法总是返回 true。
参考资料
感谢张凯峰对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家加入到 InfoQ 中文站用户讨论组中与我们的编辑和其他读者朋友交流。
评论