给javaweb项目设置一个授权
背景
JavaWeb项目发布后希望持续可控,比如:
- 发布体验版本,有授权期限,过期后不可正常访问
- 服务部署后,只允许在部署服务器运行,更换服务器后不可正常访问
- 支持离线授权
实现思路
- 给项目颁发一个licence,包含用户信息、授权时间等信息,使用非对称加密对这些信息进行数字签名。
- 使用拦截器校验licence的有效性,根据情况返回授权无效、授权过期等信息。
具体实现
重要的说明:
** 以下代码实现基于springboot**
依赖hutool
<dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.3.8</version> </dependency>
准备非对称加密证书
本例使用java自带的keytool来获取证书文件,简单记录一下keytool的使用。
keytool是个密钥和证书管理工具,位置在%JAVA_HOME%\bin\keytool.exe
,直接使用即可。
keytool --help
-----------------------------------------------
Key and Certificate Management Tool
Commands:
-certreq Generates a certificate request
-changealias Changes an entry's alias
-delete Deletes an entry
-exportcert Exports certificate
-genkeypair Generates a key pair
-genseckey Generates a secret key
-gencert Generates certificate from a certificate request
-importcert Imports a certificate or a certificate chain
-importpass Imports a password
-importkeystore Imports one or all entries from another keystore
-keypasswd Changes the key password of an entry
-list Lists entries in a keystore
-printcert Prints the content of a certificate
-printcertreq Prints the content of a certificate request
-printcrl Prints the content of a CRL file
-storepasswd Changes the store password of a keystore
生成秘钥,更多参数:
-alias 产生别名
-keystore 指定密钥库的名称(就像数据库一样的证书库,可以有很多个证书,cacerts这个文件是jre自带的,你也可以使用其它文件名字,如果没有这个文件名字,它会创建这样一个)
-storepass 指定密钥库的密码 (获取keystore信息所需的密码)
-keypass 指定别名条目的密码**(私钥的密码)**
-list 显示密钥库中的证书信息
-v 显示密钥库中的证书详细信息
-export 将别名指定的证书导出到文件
-file 参数指定导出到文件的文件名
-delete 删除密钥库中某条目
-import 将已签名数字证书导入密钥库
-keypasswd 修改密钥库中指定条目口令
-dname 指定证书拥有者信息
-keyalg 指定密钥的算法
-validity 指定创建的证书有效期多少天
-keysize 指定密钥长度
-dname 拥有者信息一般格式
CN 名称
OU 组织单位
O 组织
L 区域
ST 城市
C 国家
生成一个秘钥:
keytool -genkey -keystore "D:\keytools\test.keystore" -alias mytest -keyalg RSA -validity 30 -storepass spas123 -keypass kpas123 -dname "CN=libai, OU=com.tang, O=com.tang, L=xa, ST=sx, C=CN"
补充说明:
-genkey: 表示生成密钥对(公钥和私钥)
-keystore:每个 keytool 命令都有一个 -keystore 选项,用于指定 keytool 管理的密钥仓库的永久密钥仓库文件名称及其位置。如果不指定 -keystore 选项,则缺省密钥仓库将是宿主目录中(由系统属性的"user.home"决定)名为 .keystore 的文件。如果该文件并不存在,则它将被创建。
查看秘钥:
keytool -list -v -keystore test.keystore -storepass spas123
结果示例:
密钥库类型: jks
密钥库提供方: SUN您的密钥库包含 1 个条目
别名: mytest
创建日期: 2022-7-31
条目类型: PrivateKeyEntry
证书链长度: 1
证书[1]:
所有者: CN=libai, OU=com.ping, O=com.ping, L=xa, ST=sx, C=CN
发布者: CN=libai, OU=com.ping, O=com.ping, L=xa, ST=sx, C=CN
序列号: 21882e41
生效时间: Sun Jul 31 15:25:36 CST 2022, 失效时间: Tue Aug 30 15:25:36 CST 2022
证书指纹:
SHA1: 3B:AA:C8:AE:13:F8:47:15:A4:F1:64:6D:DE:B7:ED:C1:15:67:CE:D3
SHA256: 5D:7C:3A:D1:93:CE:FB:81:85:AC:79:B3:83:ED:5F:01:07:11:7E:71:40:C4:FA:03:EC:E6:60:D8:AD:52:7A:7C
签名算法名称: SHA256withRSA
主体公共密钥算法: 2048 位 RSA 密钥
版本: 3扩展:
#1: ObjectId: 2.5.29.14 Criticality=false
SubjectKeyIdentifier [
KeyIdentifier [
0000: B3 A9 B4 C8 BA B7 6B FA F9 88 0F 22 0C 51 73 72 …k…".Qsr
0010: B3 46 F2 89 .F…
]
]
参数说明:
-list 列出证书
-v 显示详细信息
-keystore 指定密钥库
-storepass 指定密钥库的解密密码
-rfc 以可编码方式打印证书
keytool -list -rfc -keystore ./test.keystore -storepass spas123
结果示例:
密钥库类型: jks
密钥库提供方: SUN您的密钥库包含 1 个条目
别名: mytest
创建日期: 2022-7-31
条目类型: PrivateKeyEntry
证书链长度: 1
证书[1]:
-----BEGIN CERTIFICATE-----
MIIDWTCCAkGgAwIBAgIEIYguQTANBgkqhkiG9w0BAQsFADBdMQswCQYDVQQGEwJD
TjELMAkGA1UECBMCc3gxCzAJBgNVBAcTAnhhMREwDwYDVQQKEwhjb20ucGluZzER
MA8GA1UECxMIY29tLnBpbmcxDjAMBgNVBAMTBWxpYmFpMB4XDTIyMDczMTA3MjUz
NloXDTIyMDgzMDA3MjUzNlowXTELMAkGA1UEBhMCQ04xCzAJBgNVBAgTAnN4MQsw
CQYDVQQHEwJ4YTERMA8GA1UEChMIY29tLnBpbmcxETAPBgNVBAsTCGNvbS5waW5n
MQ4wDAYDVQQDEwVsaWJhaTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
AINpyuhbVXcDHgs6vflvG+/vvzHwWsH9j5GJU2pzT6+mOjpX3OO56CyHiRbZeTI+
FwhQ1xbptBGy2OFBbTGW1lJQHVta1y3liuV3r5aUYDSUpt/RU82XzUCmF5mzAwYc
2Xm5dDZp7PsFpJ1z7vP42nAW+Sk6LYWUsrciZhIOkNDKxqHUofomc31RlcF6Xv2a
CK3eEmp+pf8Hh+kpfcOr01qcj08jjVAARMeQPnbcDOh+S41KPM0NrXUqcWwHTRl1
Y3xIs6UON1RKS7W+P8irnCjgqCq2s9LYVrUFpbdepUVnnTcK2A/ebr9eXd+bdIR2
doGBwowjulgqmRgUBv6qd9kCAwEAAaMhMB8wHQYDVR0OBBYEFLOptMi6t2v6+YgP
IgxRc3KzRvKJMA0GCSqGSIb3DQEBCwUAA4IBAQAShoUVISzFj/yzTQFmAFzRyKaF
4m4+NzLUogk2zux/9A4uiazx/Ml1sXj8IVHF6uq4PhIu0nhL5h06fQvuFxgZSdyu
OfpvA0YlMIm3Nl1qUpt0u1XvCXYajJkKQY/zMyuuAtCXakEwhTdb7mhCJ7GEgHF9
1zguEt9CyhjrTsiASmxJ4ll6tqMsDpE17oaELk3yqwjzgI0suLNV6xmRe3zHSBe3
ibzfLWeVYFpDyHksw0fIziAYgz+9KlPMTjHy6GZcrOJlle97W1O2E13MFSqf8eLZ
p8OXJ1hiDLiKQjzqyql/1jaPcPjN2R4Ea1I4F6f5cuXXpIfLLVymfG55qeYk
-----END CERTIFICATE-----
其它命令:
# 导入证书
keytool -import -alias test1 -file ./test.crt -keystore ./test.keystore -storepass spas123
# 导出证书
keytool -export -alias mytest -keystore ./test.keystore -file ./test.crt -storepass spas123
# 修改密钥库中指定条目的密码
keytool -keypasswd -alias 需修改的别名 -keypass 旧密码 -new 新密码 -storepass keystore密码 -keystore 所在的密钥库
# 修改密钥库的密码
keytool -storepasswd -keystore ./yushan.keystore(需修改口令的keystore) -storepass 123456(原始密码) -new yushan(新密码)
生成证书文件:
keytool -exportcert -alias mytest -keystore ./test.keystore -storepass spas123 -file ./test.cer
证书文件包含公钥,将来发送给客户使用
拟定licence文件
准备一个licence授权文件,将签名 + 证书同项目一起发布,通过licence文件和证书判断权限。我们用一个JSON文件来做licence,java bean如下:
@Data
@AllArgsConstructor
@NoArgsConstructor
@Accessors(chain = true)
public class Licence {
/**
* 授权序列号
*/
private String licenceId;
/**
* 供应商
*/
private String vendor;
/**
* 过期时间
*/
private Date expiration;
/**
* 服务器mac地址 保证只在指定的机器运行,缺点是生成秘钥是需要知道此地址,当然可以通过ipconfig查看,然后参数传入生成licence
*/
private String macAddress;
/**
* 数字签名
*/
private String signature;
}
如果想分模块授权,可以适当增加模块参数。
生成和校验licence
签名需要使用证书,先准备加载证书的方法(证书信息存放在yml配置文件中):
yml局部:
# 认证证书逻辑
licence:
base-path: D://KeyStore
key-store: ${licence.base-path}/test.keystore
key-alias: mytest
cert: ${licence.base-path}/test.cer
key-store-pwd: spas123
key-pwd: kpas123
auth-file: ${licence.base-path}/licence.json
准备工具类加载证书:
/**
* 证书加载工具
*/
@Component
public class KeyTools {
// 私钥存放路径
@Value("${licence.key-store}")
public String PRIVATE_KEY_FILE_PATH;
//Cer证书存放路径
@Value("${licence.cert}")
public String CER_FILE_PATH;
//私钥别名
@Value("${licence.key-alias}")
public String PRIVATE_ALIAS;
//获取keystore密码
@Value("${licence.key-store-pwd}")
public String KEYSTORE_PASSWORD;
//获取私钥所需密码
@Value("${licence.key-pwd}")
public String KEY_PASSWORD;
/**
* 获取私钥
* @return
*/
public PrivateKey getPrivateKey() {
FileInputStream is = null;
PrivateKey privateKey = null;
try {
KeyStore keyStore = KeyStore.getInstance("JKS");
is = new FileInputStream(PRIVATE_KEY_FILE_PATH);
keyStore.load(is, KEYSTORE_PASSWORD.toCharArray());
privateKey = (PrivateKey) keyStore.getKey(PRIVATE_ALIAS, KEY_PASSWORD.toCharArray());
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
if (is != null) {
is.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
return privateKey;
}
/**
* 通过 cer证书获取公钥
*/
public PublicKey getPublicKey(){
PublicKey publicKey = null;
FileInputStream in = null;
try {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
in = new FileInputStream(CER_FILE_PATH);
Certificate c = cf.generateCertificate(in);
publicKey = c.getPublicKey();
} catch (CertificateException | FileNotFoundException e) {
e.printStackTrace();
} finally {
try {
if (in != null) {
in.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
return publicKey;
}
}
准备一个工具类 完成签名生成和验签
重要的约定:
将证书中的值以字典序排列,然后生成签名
import cn.hutool.core.util.CharsetUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import lombok.extern.slf4j.Slf4j;
import java.io.UnsupportedEncodingException;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.util.Base64;
import java.util.Collection;
import java.util.Objects;
import java.util.stream.Collectors;
@Slf4j
public class MySignUtil {
private final static String PARAM_SIGN = "signature";
private static final String KEY_ALGORITHM = "SHA1withRSA";
private static Signature signature;
static {
try {
signature = Signature.getInstance(KEY_ALGORITHM);
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
}
/**
* 获取签名
*
* @param data
* @param privateKey
* @return
*/
public static String sign(byte[] data, PrivateKey privateKey) {
try {
signature.initSign(privateKey);
signature.update(data);
return new String(Base64.getEncoder().encode(signature.sign()), CharsetUtil.UTF_8);
} catch (InvalidKeyException | SignatureException | UnsupportedEncodingException e) {
e.printStackTrace();
}
return null;
}
public static String sign(Object data, PrivateKey privateKey) {
return sign(getParams(data), privateKey);
}
/**
* 验签
*
* @param data
* @param sign
* @param publicKey
* @return
*/
public static boolean verify(byte[] data, byte[] sign, PublicKey publicKey) {
try {
signature.initVerify(publicKey);
signature.update(data);
return signature.verify(Base64.getDecoder().decode(sign));
} catch (InvalidKeyException | SignatureException e) {
e.printStackTrace();
}
return false;
}
public static boolean verify(Object data, String signature, PublicKey publicKey) {
return verify(getParams(data), signature.getBytes(StandardCharsets.UTF_8), publicKey);
}
/**
* 获取参数
*
* @param obj
* @return
*/
private static byte[] getParams(Object obj) {
if (Objects.isNull(obj)) {
log.error("签名获取失败, 传入对象为null");
return null;
}
// 获取非sign参数值
JSONObject js = (JSONObject) JSON.toJSON(obj);
js.remove(PARAM_SIGN);
Collection<Object> values = js.values();
String params = values.stream().map(String::valueOf).sorted().collect(Collectors.joining());
return params.getBytes(StandardCharsets.UTF_8);
}
}
准备好内容, 转换为JSON字符串写入文件即为授权文件,生成过程。生成licence:
import org.jeecg.modules.licence.entity.Licence;
import java.io.File;
public interface ILicenceService {
/**
* 生成授权文件
*
* @param licence
* @return
*/
File generateLicence(Licence licence);
/**
* 实现签名校验
*
* @param licence
* @return
*/
boolean verifySign(Licence licence);
/**
* 签名校验 检验默认位置签名
*
* @return
*/
boolean verifySign();
/**
* 从磁盘中获取licence
*
* @return
*/
Licence getLicence();
/**
* 当前licence是否有效
*
* @return
*/
boolean isEffective();
/**
* 清空licence缓存
*
* @return
*/
boolean clearEffectiveCache();
}
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.CharsetUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import org.jeecg.modules.licence.entity.Licence;
import org.jeecg.modules.licence.service.ILicenceService;
import org.jeecg.modules.licence.util.KeyTools;
import org.jeecg.modules.licence.util.MySignUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.io.File;
import java.nio.charset.StandardCharsets;
@Service
public class LicenceServiceImpl implements ILicenceService {
@Value("${licence.auth-file}")
private String licencePath;
@Autowired
private KeyTools keyTools;
@Override
public File generateLicence(Licence licence) {
// 生成签名
String sign = MySignUtil.sign(licence, keyTools.getPrivateKey());
licence.setSignature(sign);
// 生成licence文件
String jsonStr = JSONUtil.toJsonStr(licence);
File file = FileUtil.writeString(jsonStr, licencePath, CharsetUtil.UTF_8);
return file;
}
@Override
public boolean verifySign(Licence licence) {
return MySignUtil.verify(licence, licence.getSignature(), keyTools.getPublicKey());
}
@Override
public boolean verifySign() {
Licence licence = getLicence();
return MySignUtil.verify(licence, licence.getSignature(), keyTools.getPublicKey());
}
@Override
public Licence getLicence() {
String licenceStr = FileUtil.readString(licencePath, StandardCharsets.UTF_8);
JSONObject jsonObject = JSONUtil.parseObj(licenceStr);
Licence licence = jsonObject.toBean(Licence.class);
return licence;
}
@Override
// 这里增加了一个缓存, 后面会添加拦截器, 如果每个请求都重新验证一遍证书, 会带来性能上的压力
@Cacheable(value = "licence", unless = "#result == false ")
public boolean isEffective() {
// 证书时间和licence时间是一致的, 如果证书过期签名校验不通过, licence肯定过期
boolean signVerify = this.verifySign();
if(!signVerify) {
return false;
}
Licence licence = getLicence();
if (StrUtil.isNotEmpty(licence.getMacAddress())) {
// 校验Mac地址
String macAddress = NetUtil.getLocalMacAddress();
if (!StrUtil.equals(macAddress, licence.getMacAddress())) {
return false;
}
}
return true;
}
@Override
@CacheEvict(value = "licence")
public boolean clearEffectiveCache() {
return true;
}
}
准备一个controller测试过程
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.lang.UUID;
import org.jeecg.common.api.vo.Result;
import org.jeecg.modules.licence.entity.Licence;
import org.jeecg.modules.licence.service.ILicenceService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
/**
* 证书相关信息
*/
@Controller
@RequestMapping("test/licence")
public class LicenceController {
@Autowired
private ILicenceService licenceService;
/**
* 生成licence
* @return
*/
@RequestMapping(value = "generate", method = {RequestMethod.GET, RequestMethod.POST})
public Result generateLicence() {
Licence licence = new Licence();
licence.setLicenceId(UUID.fastUUID().toString(true))
.setExpiration(DateUtil.offsetDay(DateUtil.date(), 30))
.setVendor("XX科技有限责任公司");
// 这里直接获取了本机的网卡Mac 实际操作中可以通过参数掺入的方式
String macAddress = NetUtil.getLocalMacAddress();
licence.setMacAddress(macAddress);
licenceService.generateLicence(licence);
return Result.OK(licence);
}
/**
* 校验licence 验签 这里证书过期或者licence被修改都会导致验签失败
* @return
*/
@RequestMapping(value = "verify", method = {RequestMethod.GET, RequestMethod.POST})
public Result verifyLicence() {
return Result.OK(licenceService.verifySign());
}
}
添加licence拦截器
拦截所有的请求, 校验授权是否有效:
import cn.hutool.core.date.DateUtil;
import com.alibaba.fastjson.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.api.vo.Result;
import org.jeecg.modules.licence.entity.Licence;
import org.jeecg.modules.licence.service.ILicenceService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
/**
* 授权校验拦截器, 校验licence是否合法
*/
@Slf4j
@Component
public class LicenceInterceptor implements HandlerInterceptor {
@Autowired
private ILicenceService licenceService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (licenceService.isEffective()) {
return true;
}else{
Licence licence = licenceService.getLicence();
if (licence == null) {
output(Result.error("项目未授权"), response);
}else{
if (DateUtil.compare(DateUtil.date(), DateUtil.parseDate(licence.getExpiration())) >= 0) {
// 有授权, 但是过期了
output(Result.error("授权已过期"), response);
}else{
output(Result.error("项目授权无效"), response);
}
}
}
return false;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
}
/**
* 向页面返回信息
*
* @param result
* @param response
*/
public void output(Result result, HttpServletResponse response) throws IOException {
response.setContentType("application/json; charset=utf-8");
PrintWriter writer = response.getWriter();
writer.print(JSONObject.toJSONString(result));
writer.close();
response.flushBuffer();
}
}
当然记得注册一下拦截器。这样就完成了web项目授权,只有授权有效的情况下才能正确返回。
其他注意事项
- 向客户发布项目时,只附带licence.json和test.cer,自己留好keystore文件。
- 应该给用户一些友好的提示,比如在证书快过期时提醒用户及时获取授权;已过期时提醒用户是因为授权原因导致服务不可用等。