写点什么

Java 深度历险(九)——Java 安全

  • 2011-05-27
  • 本文字数:5918 字

    阅读完需:约 19 分钟

安全性是 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 中文站用户讨论组中与我们的编辑和其他读者朋友交流。

2011-05-27 00:0014743

评论

发布
暂无评论
发现更多内容

每日一题 | LeetCode 1 两数之和

武师叔

Python 算法 JAV A Leet Code 6月月更

过去一周区块链热点回顾|BAYC项目具有被无限铸币的风险

区块链前沿News

Hoo

三大特性,多个场景,Serverless 应用引擎 SAE 全面升级

Serverless Devs

阿里云 Serverless 微服务

同心助力,战役有AI

开源社

人工智能 疫情防控

5分钟了解SDN控制平面

穿过生命散发芬芳

SDN网络 6月月更

scp 高效操作之避免 zsh 路径展开

Nick

Linux zsh 6月月更 高效操作 scp

FinClip2022重要功能汇总

Speedoooo

微信小程序 APP开发 小程序容器 微信登录

让开发效率飞速提升的跨端开发神器

Geek_99967b

小程序 小程序容器

阿里6月终于有HC了!耗时两月足足面试13轮成功入职阿里!拿到32*15Offer

Java全栈架构师

Java spring 程序员 面试 程序人生

Flutter 利用 Redux 中间件完成购物清单离线存储

岛上码农

flutter ios 前端 安卓开发 6月月更

社区活动 | Apache Doris 社区长期征文活动&演讲议题征集 正式开始啦!

SelectDB

开源社区 apache doris 征文投稿 议题征集 社区活动

开源生态|超实用开源License基础知识扫盲帖(上)

Orillusion

开源 WebGL 元宇宙 Metaverse webgpu

Hexo + Github从零搭建个人博客

梁歪歪 ♚

Hexo 博客搭建

跨平台方案的比较

Geek_99967b

小程序 小程序容器

leetcode 51. N-Queens N 皇后(困难)

okokabcd

LeetCode 搜索 算法与数据结构

使用APICloud开发app的动态权限及Android平台targetSdkVersion设置教程

YonBuilder低代码开发平台

android 权限管理 APICloud

App中快速复用微信登录授权的一种方法

Speedoooo

APP开发 微信授权 微信登录

AIOps落地五大原则(一):大势所趋

BizSeer必示科技

PC端实现运营小程序,是否能再创PC时代又一春!

Geek_99967b

小程序 小程序转app

Java设计模式学习总结

梁歪歪 ♚

设计模式

企业网站如何快速被搜索引擎收录

源字节1号

如何使用阿里云 CDN 对部署在函数计算上的静态网站进行缓存

Serverless Devs

Serverless 前端 前端工具

开放银行引入第三方生态,系统风控助力事前-中风险监控

Speedoooo

数字化 开放银行 异业合作

InterpreterPattern-解释器模式

梁歪歪 ♚

设计模式

深入浅出-如何安全的传输密码

梁歪歪 ♚

加密

Flutter的整体架构

Geek_99967b

小程序 小程序容器

面试官:执行一条 SQL 语句,期间会发生什么?

Java全栈架构师

Java MySQL 数据库 程序员 面试

【前端每日一学】vue框架的深入学习

恒山其若陋兮

6月月更

「技术人生」第8篇:如何画业务大图

阿里巴巴中间件

阿里云 云原生 技术文章

2022年SaaS行业十大趋势:SaaS的新机遇有哪些?

小炮

孙勇男:实时视频 SDK 黑盒测试架构丨Dev for Dev 专栏

RTE开发者社区

自动化测试 Dev for Dev

Java深度历险(九)——Java安全_Java_成富_InfoQ精选文章