一、背景介绍
某个供应商服务需要部署到海外,如果海外多个地区需要部署多个服务,最好能实现统一登录,这样可以减轻用户的使用负担(不用记录一堆密码)。由于安全问题(可能会泄露用户数据),海外服务不能直连公司sso服务端,因此需要其他的方案解决安全问题。最终的安全方案中需要用到SSL双向认证进行数据的传输和交互,并且只指定某些个别接口实现SSL双向认证。在此背景下,这篇文章介绍基于tomcat的SSL双向认证的简单实现。
二、SSL简单介绍
SSL(Secure Sockets Layer 安全套接层)就是一种协议(规范),用于保障客户端和服务器端通信的安全,以免通信时传输的信息被窃取或者修改。
1.怎样保障数据传输安全?
客户端和服务器端在进行握手(客户端和服务器建立连接和交换参数的过程称之为握手)时会产生一个“对话密钥”(session key),用来加密接下来的数据传输,解密时也是用的这个“对话密钥”,而这个“对话密钥”只有客户端和服务器端知道。也就是说只要这个“对话密钥”不被破解,就能保证安全。
2. 客户端证书和服务器端证书
客户端证书和服务器端证书用于证明自己的身份,就好比每个人都有一张身份证,这种身份证是唯一的。一般来说,只要有服务器端的证书就可以了,但是有时需要客户端提供自己的证书,已证明其身份。
三、生成自签名的服务器端证书和导入服务器端信任证书库
一般证书可以使用权威机构颁发的证书,如:veri sign,百度使用的就是veri sign颁发的证书,这样的权威证书机构是受信任的,但是这些机构颁发的证书往往是需要收费的,这样的证书也难得到。对于小型企业来说为了节约成本,常常使用自签名的证书。
接下来使用JDK keytool工具来签发证书,如果未安装JDK,请先安装JDK(本文使用的是JDK8)。本文所有的证书文件都放到/cert/test1(操作系统centos),您可以选择一个目录来存放。
1.制作服务端密钥库
keytool -genkey -v -alias server -keyalg RSA
-keystore /cert/test1/server.keystore -validity 36500
-ext SAN=dns:test-ssl,ip:10.1.x.x
-dname "CN=test,OU=test,O=test,L=hz,ST=hz,C=cn"
注意:SAN填写的是域名,IP填写是服务端IP。SAN和IP是解决谷歌浏览器证书无效的关键。
2.制作客户端密钥库
keytool -genkey -v -alias client -keyalg RSA -storetype PKCS12
-keystore /cert/test1/client.p12 -dname "CN=test,OU=test,O=test,L=hz,ST=hz,C=cn"
3.客户端证书导入服务端密钥库
由于不能直接将p12导入,需要先从客户端密钥库导出证书,再将导出的证书导入服务端密钥库。
keytool -export -alias client -keystore /cert/test1/client.p12
-storetype PKCS12 -storepass 123456 -rfc -file /cert/test1/client.cer
keytool -import -v -file /cert/test1/client.cer -keystore /cert/test1/server.keystore
4.导出服务端密钥库证书
keytool -keystore /cert/test1/server.keystore -export -alias server -file /cert/test1/server.cer
5.配置tomcat
5.1配置server.xml
找到conf目录下的server.xml文件,增加如下配置。
<Connector port="8443" protocol="org.apache.coyote.http11.Http11NioProtocol"
SSLEnabled="true"maxThreads="150" scheme="https" secure="true"
clientAuth="true" sslProtocol="TLS"
keystoreFile="/cert/test1/server.keystore" keystorePass="123456"
truststoreFile ="/cert/test1/server.keystore" truststorePass="123456"
/>
说明:
- clientAuth为true表示开启SSL双向认证
- keystoreFile指定服务器端的证书位置
- truststoreFile指定服务器端信任证书库
5.2配置web.xml
找到conf目录下的server.xml文件,增加如下配置。
<security-constraint>
<web-resource-collection>
<web-resource-name>SSL</web-resource-name>
<url-pattern>/ssl_test/*</url-pattern>
</web-resource-collection>
<user-data-constraint>
<description>SSL required</description>
<transport-guarantee>CONFIDENTIAL</transport-guarantee>
</user-data-constraint>
</security-constraint>
说明:
- 如果不加入这个配置,那么所有访问的地址都必须要使用SSL才能访问,有时我们可能只需要通过某个或者某些SSL地址获取客户端证书来认证用户身份,认证成功后不需要使用SSL来进行访问(可以配置多个security-constraint)
- url-pattern:指定需要SSL才能进行访问的地址(/ssl_test/*)
- transport-guarantee:合法值为NONE、 INTEGRAL或CONFIDENTIAL,transport-guarantee为NONE值将对所用的通讯协议不加限制。INTEGRAL值表示数据必须以一种防止截取它的人阅读它的方式传送。虽然原理上(并且在未来的HTTP版本中),在 INTEGRAL和CONFIDENTIAL之间可能会有差别,但在当前实践中,他们都只是简单地要求用SSL
- 创建SSLServlet获取客户端证书
6.编写用来获取客户端证书的filter及测试接口类
客户端证书验证拦截器(拦截路径:/ssl_test/*)
package com.example.demo;
import java.io.IOException;
import java.security.cert.X509Certificate;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.annotation.WebFilter;
/**
* description:MyFilter
*
* @author: lgq
* @create: 2024-02-02 10:55
*/
@WebFilter(urlPatterns = "/ssl_test/*")
public class MyFilter implements Filter {
private static final String REQUEST_ATTR_CERT = "javax.servlet.request.X509Certificate";
private static final String SCHEME_HTTPS = "https";
/**
* web应用启动时,web服务器将创建Filter的实例对象,并调用init方法,读取web.xml的配置,完成对象的初始化功能,
* 从而为后续的用户请求做好拦截的准备工作(filter对象只会创建一次,init方法也只会执行一次,开发人员通过init的参数,
* 可或得代表当前filter配置信息的FilterConfig对象)
* @param filterConfig
* @throws ServletException
*/
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
/**
* 这个方法完成实际的过滤操作,当客户请求访问与过滤器相关联的URL的时候,Servlet过滤器将先执行doFilter方法,FilterChain参数用于访问后续过滤器
* @param request
* @param response
* @param filterChain
* @throws IOException
* @throws ServletException
*/
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
throws IOException, ServletException {
X509Certificate[] certs = (X509Certificate[]) request.getAttribute(REQUEST_ATTR_CERT);
if (certs != null) {
int count = certs.length;
System.out.println("共检测到[" + count + "]个客户端证书");
for (int i = 0; i < count; i++) {
X509Certificate cert = certs[i];
System.out.println("客户端证书 [" + cert.getSubjectDN() + "]: ");
System.out.println("证书是否有效:" + (verifyCertificate(cert) ? "是" : "否"));
System.out.println("证书详细信息:\r" + cert.toString());
}
filterChain.doFilter(request, response);
} else {
if (SCHEME_HTTPS.equalsIgnoreCase(request.getScheme())) {
System.out.println("这是一个HTTPS请求,但是没有可用的客户端证书");
} else {
System.out.println("这不是一个HTTPS请求,因此无法获得客户端证书列表 ");
}
}
System.out.println("我是过滤器,我进来了");
}
/**
* filter创建后会保存在内存中,当web应用移除或者服务器停止时才销毁,该方法在Filter的生命周期中仅执行一次,在这个方法中,可以释放过滤器使用的资源
*/
@Override
public void destroy() {
}
/**
*
* 校验证书是否过期
*
*
* @param certificate
* @return
*/
private boolean verifyCertificate(X509Certificate certificate) {
boolean valid = true;
try {
certificate.checkValidity();
} catch (Exception e) {
e.printStackTrace();
valid = false;
}
return valid;
}
}
启动类(服务部署到tomcat)
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.ServletComponentScan;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
@SpringBootApplication
@ServletComponentScan
public class DemoApplication extends SpringBootServletInitializer {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
return builder.sources(DemoApplication.class);
}
}
pom依赖(打war包)
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.4.3</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>demo</name>
<description>Demo project for Spring Boot</description>
<packaging>war</packaging>
<properties>
<java.version>8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>
<!--spring boot tomcat(默认可以不用配置,但当需要把当前web应用布置到外部servlet容器时就需要配置,并将scope配置为provided)-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<finalName>test</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-war-plugin</artifactId>
<version>2.1.1</version>
<configuration>
<failOnMissingWebXml>false</failOnMissingWebXml>
</configuration>
</plugin>
</plugins>
</build>
</project>
tomcat下服务目录(工程路径/test)
启动服务命令
客户端ssl证书认证接口
package com.example.demo;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* description:SSLTestController
*
* @author: lgq
* @create: 2024-01-25 10:42
*/
@RestController
@RequestMapping("/ssl_test")
public class SSLTestController {
@GetMapping("/hello")
public String auth() {
return "Hello, I am the server! Your client's SSL certificate has been authenticated!";
}
}
不需要认证客户端证书的接口
package com.example.demo;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* description:NoSSLTestController
*
* @author: lgq
* @create: 2024-01-25 10:42
*/
@RestController
@RequestMapping("/no_ssl_test")
public class NoSSLTestController {
@GetMapping("/hello")
public String auth() {
return "Hello, I am the server!";
}
}
7.测试
7.1 浏览器访问测试
7.1.1ssl双向认证测试
用浏览器访问http://10.1.x.x:8080/test/no_ssl_test/hello
细心的读者可能发现链接已经跳转到了https://127.0.0.1:8443/SSL/SSLServlet ,这是由于这个地址被设置为需要SSL才能访问,所以跳转到了这个地址。访问时页面提示如下:
为了不出现这样的警告信息,我们可以导入服务器端证书到客户端,双击服务端证书
选择当前用户
将证书放入可信任的根证书列表 ,随后安装成功
再次访问: http://10.1.x.x:8080/test/ssl_test/hello,出现如下错误
由于我们访问的接口是双向认证,所以也需要客户端的证书,我们接下来导入客户端证书
自动选择证书存储
输入证书密钥,随即安装成功
第三次访问: http://10.1.x.x:8080/test/ssl_test/hello,结果如下所示
需要选择客户端证书
输出结果如下
tomcat 日志如下,(证书是否有效:是)表示客户端证书已经通过服务端验证
7.1.2 不验证客户端证书
访问地址http://10.1.x.x:8080/test/no_ssl_test/hello, 发现没有跳转到8443端口,正常返回内容如下