UKEY - 常见安全的 2FA 实现

6/22/2024 Authentication

什么是 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();
    }
}
1
2
3
4
5
6
7

ntlsUtil 是从奥联接口中导入的全局工具类

import { ntlsUtil } from '@/utils/ntls-plugin'
1

如果 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);
}
1
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;
    }
}
1
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 中的keyindexcontainer存在前端维护的两个变量中,在后续登陆时使用

用户注册

系统要求用户注册时录入用户所使用 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
    })
},
1
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');
}
1
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
        }
    })
}
1
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);
1

ukey_validentity_val为环境检测时获取的 UKEY 信息keyindexcontainerpass为 UKEY 的 PIN 码,inData为登陆的表单原文(Json 数据),如

"{\"account\":\"wx\", \"password\":\"123456\", \"verify_code\":\"dQ3k\", \"PIN\":\"123456\"}"
1

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
    })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

注意传入的原文数据需要是 Base64 编码,并且在签名时,必须要规定传参formatasn.1,才能签名得到 Base64 编码的密文?奥联真的是一坨构思

另外这里非常不优雅的把一系列信息存到了 localStorage,因为在后续的转账操作中要用到,为什么要这么写??唉,是这样

  1. 之前在检测环境时说过,是否检测的条件是 websocket 是否连接,这就造成登录时用的这个连接一直保持,于是在后续并不会进行环境检测
  2. 所以呢,在转账页面,没有进行环境检测,自然就不会去遍历钥匙,就不会获得keyindexcontainer,所以这里我偷懒直接存了,到时候直接取了用

正确的流程应该是:在登陆成功后,主动断开 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);
}
1
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;
}
1
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;
}
1
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;
}
1
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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

后记

关于这个方案,用户不可能随时携带 U 盾,手机才是最好的替代品。密码 + 手机就成了最佳的双因素认证方案(from 阮一峰)

另外,奥联的接口,谁用谁知道,无敌了

Last Updated: 9/17/2024, 4:16:37 PM
妖风过海
刘森