写点什么

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:0014071

评论

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

Django笔记三十三之缓存操作

Hunter熊

Python redis django 缓存

一文盘点PoseiSwap近期的生态利好

股市老人

深入理解 ThreadLocal:原理及源码解读

Java你猿哥

Java 源码 多线程 ssm ThreadLocal

熬了一个月肝出这份32W字Java面试手册,在Github标星31K+

程序知音

Java java面试 后端技术 Java八股文 Java面试题

开源字节 CRM 系统

源字节1号

开源 软件开发 小程序开发

斯坦福、Nautilus Chain等联合主办的 Hackathon 活动,现已接受报名

股市老人

Turbo Boost Switcher Pro for mac(cpu温度监测工具) 2.10.2

真大的脸盆

Mac Mac 软件 CPU温度监测工具

一文读懂大语言模型

俞凡

人工智能

mac端好用的建模软件分享~

真大的脸盆

Mac Mac 软件 建模工具 三维建模软件

AIGC背后的技术分析 | 机器学习中的卷积神经网络

TiAmo

卷积神经网络 AIGC

由斯坦福、Nautilus Chain等联合主办的 Hackathon 活动,现已接受报名

鳄鱼视界

轻松建模、编辑、分析尽在Rhino 7中文激活版~

真大的脸盆

Mac 3D Mac 软件 建模软件 3d建模

吃透阿里2023版Java性能优化小册后,我让公司系统性能提升了200%

做梦都在改BUG

Java 性能优化 性能调优

由斯坦福、Nautilus Chain等联合主办的 Hackathon 活动,现已接受报名

西柚子

UserDAO(UWT)会是WEB3.0中新的宠儿吗?

币离海

Web3.0 UserDAO UWT

硬核!阿里出品2023版Java架构师面试指南,涵盖Java所有核心技能

做梦都在改BUG

Java java面试 Java八股文 Java面试题 Java面试八股文

Cloud Studio 高阶玩家:强大的 YAML 模板

CODING DevOps

如何让 300 万程序员爱上 CODING?

CODING DevOps

低代码实现探索(五十八)低代码平台

零道云-混合式低代码平台

Python潮流周刊#2:Rust 让 Python 再次伟大

Python猫

Python

2023-05-20:go语言的slice和rust语言的Vec的扩容流程是什么?

福大大架构师每日一题

golang rust 福大大

Midjourney|文心一格prompt教程[进阶篇]:Midjourney Prompt 高级参数、各版本差异、官方提供常见问题

汀丶人工智能

人工智能 AI绘画 MidJourney 文生图 prompt learning

太猛了!Github大佬那白嫖的分布式进阶宝典,啃完感觉能吊锤面试官

Java你猿哥

Java 架构 软件开发 ssm 架构设计

双模齐下,提质增效:CODING 携手知微共创 BizDevOps 体系新篇章

CODING DevOps

Midjourney|文心一格 Prompt:完整参数列表、风格汇总、文生图词典合集

汀丶人工智能

人工智能 AI绘画 MidJourney 文生图 prompt learning

使用Go语言实现工厂模式的三种方式

Jack

美团二面:聊聊ConcurrentHashMap的存储流程

Java你猿哥

Java 源码 ssm ConcurrentHashMap 红黑树

求爷爷告奶奶,阿里大佬才甩出这份Spark+Hadoop+中台实战pdf

程序知音

大数据 hadoop spark 后端技术

专精特新 ︱ 腾讯云 CODING 助力消费电子类企业高速发展期的研运一体化

CODING DevOps

文心一言 VS 讯飞星火 VS chatgpt (19)-- go语言的slice和rust语言的Vec的扩容流程是什么?

福大大架构师每日一题

福大大 文心一言 讯飞星火

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