UKEY - 常见安全的 2FA 实现
什么是 2FA?
2FA,Two-Factor Authentication,即双因素认证
- 传统的账号密码为单重认证,如 QQ 的登录;Stream 的登录则为双因素,一重为账号密码,二为手机接收的认证码短信
密码 + 某种个人物品是常见的双因素组合,如银行使用的 U 盾(一个 U 盘),在此处 U 盾 充当“某种个人物品”的作用,每位用户将配备各自的 U 盾,内置有用户的签名证书与加密证书,同时 U 盾的访问受 PIN 码保护
一种典型实现方案为
- 用户在完成口令、验证码的输入后,前端调用 USBKEY 的接口对登录请求数据进行签名,并且将该签名作为一个字段加入 HTTP 请求中
- 后端系统会部署一个签名验签服务器,接收到前端登录请求后,利用签名验签服务器完成验签,验签正确则身份鉴别通过,而后再进行密码的认证,实现双因素认证
本文采用奥联的 USBKEY 以及其相对应的密码机实现 U 盾的 2FA 认证。真的不得不说,奥联给的接口简直是一坨大芬🤮,文档更是牛头不对马嘴
实现
一些前置工作
- 用户 UKEY 中所使用的签名证书需要在后端签名验签服务器中注册
- 下载安装 UKEY 的插件环境
- 需要一个倒霉蛋将 U 盘从西安邮到秦皇岛
UKEY 环境检测
采用 WebSocket 形式的接口(构式奥联提供的)对前端表单数据进行 SGD_SM3_SM2 进行签名,该 UKEY 接口内部写死 SignerID 为"1234567812345678"
(默认 ID),这在后续验签时需保持一致
检测插件环境以及是否插入 U 盾
envCheck(){
if(!ntlsUtil.wsObj){
ntlsUtil.websocketInit(this.check_plugin_exist, null, this.check_plugin_exist);
} else {
this.check_plugin_exist();
}
}
2
3
4
5
6
7
ntlsUtil 是从奥联接口中导入的全局工具类
import { ntlsUtil } from '@/utils/ntls-plugin'
如果 ntlsUtil.wsObj 不为空,即 websocket 连接打开,则认为已经检查过不进行后续操作(因为检查过程会主动打开 websocket,若在已连接的情况下再打开会报错,所以这里这么处理,但在后续引发了其他问题)
插件检查
//检查插件
check_plugin_exist(){
if(ntlsUtil.pluginExist==false){
//连接不成功,插件未安装或者服务未启动
alert("连接不成功,插件未安装或者服务未启动")
return false;
}
//step4 检测是否插入ukey, 所有接收信息的回调函数名称都为当前发送消息的action名称
ntlsUtil.func.enumerate_ukey_user(this.enumerate_ukey_user);
}
2
3
4
5
6
7
8
9
10
ntlsUtil.func.enumerate_ukey_user()
是一个异步函数,传参为回调函数,对异步返回的数据进行处理,this.enumerate_ukey_user
如下
enumerate_ukey_user(message){
if(message == null){
alert("WebSocket检测请求失败")
return false;
}
var ukey_exist = false;
for(var i = 0; i < message.data.usbkey.length; i++){
var key_data = message.data.usbkey[i];
if(typeof key_data.keytype != 'undefined'){
if(this.check_key_type == true && key_data.keytype != this.key_type ){ }
else{
//keytype=file 在私钥标识中查找,从每个identity.0.type 查找sm2/sm9/...
var this_ukey_exist = false;
var length = key_data.identity.length;
for (var m = 0; m < length; m++){
//只匹配需要的alg
if(key_data.identity[m].type.toUpperCase().indexOf(this.alg_type) != -1){
this_ukey_exist = true;//局部
}
}
if(this_ukey_exist){
ukey_exist = true;//全局
}
}
} else {
//key_type=usbkey 在产商中查找
if(key_data.manufacturer.toUpperCase().indexOf(alg_type)!==-1 || key_data.alg.toUpperCase().indexOf(this.alg_type)!==-1 ){
ukey_exist = true;
}
}
}
if(ukey_exist == false){
alert('未检测到'+this.alg_type+'私钥标识'); //按扭功能 ,检测sm2/sm9等 UKEY是否插件
return false;
}else{
console.log('检测到'+this.alg_type+'私钥标识',true,true);
console.log(JSON.stringify(message));
this.keyindex = message.data.usbkey[0].keyindex;
this.container = message.data.usbkey[0].identity[0].container;
console.log("keyindex: " + this.keyindex + "\ncontainer: " + this.container);
this.ret = true;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
请求完毕后,将 UKEY 中的keyindex
和container
存在前端维护的两个变量中,在后续登陆时使用
用户注册
系统要求用户注册时录入用户所使用 UKEY 中的证书信息,同样采用异步函数获取
handleRegister() {
this.$refs.registerForm.validate(valid => {
if (valid) {
this.loading = true
let ukey_val = this.keyindex;
let identity_val = this.container;
ntlsUtil.func.get_cert_content(ukey_val, identity_val, 1, this.doRegister);
}
})
},
// message 中为证书信息
doRegister(message){
if(!message || !message.data){
alert("获取证书内容失败");
return false;
}
console.log(message.data)
// 截取cert
let cert = message.data.replace(/[\r\n]/g,"");
cert = cert.substr(27, cert.length)
cert = cert.substr(0, cert.indexOf("-"))
console.log(cert)
this.registerForm.certificate = cert;
register(this.registerForm).then(() => {
this.$message({
message: '注册成功,即将返回登录页面!',
type: 'success',
duration: 2000
})
this.loginForm.username = this.registerForm.username
this.loginForm.password = this.registerForm.password
setTimeout(() => {
this.switchLogin = true
}, 1500)
this.$refs['registerForm'].resetFields()
}).finally(() => {
this.loading = false
})
},
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
签名及登录
表单数据在签名前需要进行 Base64 编码处理
encodeBase64(str){
return Buffer.from(str, 'utf8').toString('base64');
}
2
3
用户注册成功,并且在页面正常获得 UKEY 信息后,执行登陆操作,pass 为 UKEY 的 PIN 码,需要用户在前端手动输入
// 登录
handleLogin() {
this.$refs.loginForm.validate(valid => {
valid = valid && this.ret // this.ret 在环境检测通过后置为 true,valid 为表单数据合法性
if (valid) {
this.loading = true
this.loginForm.iniData = JSON.stringify(this.loginForm);
console.log(this.loginForm.iniData);
let inData = this.encodeBase64(this.loginForm.iniData);
let ukey_val = this.keyindex;
let identity_val = this.container;
let pass = this.loginForm.key;
let hashtype = "";
let format = "asn.1";
// 注意这里要标明 format 为 asn.1
ntlsUtil.func.data_sm2_signature(ukey_val, identity_val, pass, inData, hashtype, format, this.doLogin);
} else {
console.log('error submit!!');
alert("error submit!")
return false
}
})
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
核心在于这条加密函数
ntlsUtil.func.data_sm2_signature(ukey_val, identity_val, pass, inData, hashtype, format, this.doLogin);
ukey_val
和identity_val
为环境检测时获取的 UKEY 信息keyindex
和container
,pass
为 UKEY 的 PIN 码,inData
为登陆的表单原文(Json 数据),如
"{\"account\":\"wx\", \"password\":\"123456\", \"verify_code\":\"dQ3k\", \"PIN\":\"123456\"}"
this.doLogin
为回调函数,传入的message
为签名数据
// 打请求
doLogin(message){
if(!message || !message.data){
alert("签名失败, 请检查UKey是否插入或PIN码是否正确, 或PIN码是否被锁定");
this.loading = false;
return false;
}
console.log("签名所用证书为: " + this.cert + "\n前端签名原文为" + this.encodeBase64(this.loginForm.iniData) + "\n前端签名所得密文为: " + message.data)
this.loginForm.signature = message.data;
this.$store.dispatch('user/login', this.loginForm).then(() => {
localStorage.setItem("pin", this.loginForm.key);
localStorage.setItem("keyindex", this.keyindex);
localStorage.setItem("container", this.container);
this.$router.push({ path: this.redirect || '/' })
this.loading = false
}).catch(() => {
this.loading = false
})
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
注意传入的原文数据需要是 Base64 编码,并且在签名时,必须要规定传参format
为asn.1
,才能签名得到 Base64 编码的密文?奥联真的是一坨构思
另外这里非常不优雅的把一系列信息存到了 localStorage,因为在后续的转账操作中要用到,为什么要这么写??唉,是这样
- 之前在检测环境时说过,是否检测的条件是 websocket 是否连接,这就造成登录时用的这个连接一直保持,于是在后续并不会进行环境检测
- 所以呢,在转账页面,没有进行环境检测,自然就不会去遍历钥匙,就不会获得
keyindex
和container
,所以这里我偷懒直接存了,到时候直接取了用
正确的流程应该是:在登陆成功后,主动断开 websocket 连接,在转账页面重复上述流程获取钥匙信息再进行签名操作,但是他就给了我 1k,我懒得写了,反正甲方没找我麻烦
验签实现
前端将原始表单数据(原文)和签名数据(密文)一同打在后端接口上,后端接收到请求后,首先根据用户名从数据库中取出用户证书,再对前端传来的原文和密文通过证书和签名验签服务器进行验证,返回true/false
,实现一重认证
Controller 层
@PostMapping("/login")
public Result doLogin(@RequestBody LoginInfoDTO user,HttpServletRequest req) throws CryptoException, UnsupportedEncodingException {
System.out.println("signature: "+ user.getSignature());
HttpSession session = req.getSession();
String gencode = (String) session.getAttribute("index_code");
if(StringUtils.isEmpty(user.getCode())){
return Result.ERROR("验证码不能为空");
}
System.out.println("验证码: " + gencode);
if (!gencode.toLowerCase().equals(user.getCode().toLowerCase())){
return Result.ERROR("验证码错误");
}
//验证用户登录签名信息
if(StringUtils.isEmpty(user.getSignature())){
return Result.ERROR("签名信息不能为空");
}
String jwtToken = userService.doLogin(user);
if (jwtToken == null) {
return Result.ERROR("用户名或密码错误!!!!!!");
}
System.out.println("用户登录");
return Result.OK(jwtToken);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
主要的验证业务再String jwtToken = userService.doLogin(user)
这一行
Service 层
@Override
public String doLogin(LoginInfoDTO user) {
User uu = userMapper.findByName(user.getUsername());
String cert = uu.getCertificate();
String iniData = user.getIniData();
String signData = user.getSignature();
System.out.println("iniData: " + iniData);
System.out.println("signature: " + signData);
boolean flag = OlymSignature.verifySignature(cert, iniData, signData);
if(flag)
return doLogin(user.getUsername(), user.getPassword());
else
return null;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
第一重认证在boolean flag = OlymSignature.verifySignature(cert, iniData, signData);
public static boolean verifySignature(String cert, String inData, String signed){
boolean flag = false;
try{
flag = verify(cert, inData, signed);
}catch (Exception e){
System.out.println("---------->验签出错");
e.printStackTrace();
}
return flag;
}
2
3
4
5
6
7
8
9
10
verify 函数封装一个 HTTP 请求,打向后端内网中的密码机,获取验签结果,这里的 cert 是注册时用户存入数据库的那份证书,inData 和 signature 分别是从前端打来的表单原文信息和前端的签名信息
- 所以这里的验签实际上就是,根据用户在后端存的证书 cert,对用户的原文加密,并和用户在前端的密文进行比对,若一致则认证通过
public static boolean verify(String cert, String inData, String signature) throws Exception {
if(cert == null){
cert = Cert;
}
byte[] signerIDBytes = SignerID.getBytes();
Integer signerIDLen = signerIDBytes.length;
String signerID = Base64.getEncoder().encodeToString(signerIDBytes);
byte[] inDataBytes = inData.getBytes();
Integer inDataLen = inDataBytes.length;
System.out.println(inDataLen);
inData = Base64.getEncoder().encodeToString(inDataBytes);
System.out.println("验签所使用的证书为: " + cert);
System.out.println("验签所使用的原文为: " + inData);
System.out.println("验签所使用的签名密文为: " + signature);
System.out.println("验签所使用的签名ID为: " + signerID);
VerifySignedDataReq verifySignedDataReq = new VerifySignedDataReq();
verifySignedDataReq.setSignMethod(SGD_SM3_SM2);
verifySignedDataReq.setType(Type);
verifySignedDataReq.setCert(cert);
// Type为1时certSN没用,将使用cert
verifySignedDataReq.setInData(inData);
verifySignedDataReq.setInDataLen(inDataLen);
// 这个ID默认为"1234567812345678"
verifySignedDataReq.setSignerID(signerID);
verifySignedDataReq.setSignerIDLen(signerIDLen);
verifySignedDataReq.setSignature(signature);
verifySignedDataReq.setVerifyLevel(VerifyLevel);
System.out.println(verifySignedDataReq);
Integer verifySignedDataResult = SignVerifyUtil.verifySignedData(verifySignedDataReq);
System.out.println(
"单包验证数字签名结果:" + Objects.equals(SVSRESPONSE_RESPVALUE_SUCCESS, verifySignedDataResult));
return SignVerifyUtil.verifySignedData(verifySignedDataReq) == 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
而后的二重认证doLogin(user.getUsername(), user.getPassword())
就是数据库密码认证,不再赘述
外部 jar 引入打包
IDEA 需要在Project Structure
中添加Modules
,同时在pom.xml
中配置导出
<dependencies>
<!--外部引用,打包时要包含进去-->
<dependency>
<groupId>obymtect.ibc</groupId>
<artifactId>sign</artifactId>
<version>1.0.1</version>
<scope>system</scope>
<systemPath>${pom.basedir}/lib/olymibc.jar</systemPath>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!-- 在打包时将引用的外部jar引入到当前项目包中 -->
<includeSystemScope>true</includeSystemScope>
</configuration>
</plugin>
</plugins>
</build>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
后记
关于这个方案,用户不可能随时携带 U 盾,手机才是最好的替代品。密码 + 手机就成了最佳的双因素认证方案(from 阮一峰)
另外,奥联的接口,谁用谁知道,无敌了