基于SSL/TSL证书实现HTTPS双向认证2-实践方案

本文介绍了以下内容:

  1. HTTPS双向认证的过程。
  2. 笔者所要解决的问题场景以及进行的前期准备(申请客户端证书)。
  3. 前端携带证书发请求的几种方案,以及方案的代码样例和优缺点等。

双向认证介绍

上篇文章已经介绍了HTTPS单向认证的原理和过程,详见基于SSL/TSL证书实现HTTPS双向认证1-原理介绍
HTTPS中的单向认证即为服务端认证,而客户端认证是可选的。
单向认证的过程中,客户端从服务器端下载服务器端公钥证书进行验证,然后建立安全通信通道。

HTTPS在TLS标准中是提供了双向认证的能力的,双向认证是指客户端和服务器端都需要验证对方的身份,在建立Https连接的过程中,握手的流程比单向认证多了几步。
双向认证的过程中,客户端除了需要从服务器端下载服务器的公钥证书进行验证外,还需要把客户端的公钥证书上传到服务器端给服务器端进行验证,等双方都认证通过了,才开始建立安全通信通道进行数据传输。

问题场景描述

目前要在移动端新增功能,该功能的后端接口原本是只能内网访问,由于新功能只能在外网环境下使用,现需要将该API接口暴露在公网环境下,为了安全性考虑,需要给移动端增加客户端认证,由此笔者对双向认证展开调,并提出了几个实践方案以及其优缺点。

申请客户端证书

需要准备客户端证书,包括:

  • cert.pem:公钥证书,
  • private_key.pem:私钥证书,
  • trust_cert_chain_server.pem:根证书。

客户端的工作需要以下步骤:

  1. 生成客户端证书和私钥,可以使用openssl命令或者其他工具生成,注意证书需要符合服务器端的要求。
  2. 把客户端证书上传到服务器端,服务器端需要把客户端证书添加到信任列表中。
  3. 在前端代码中添加证书验证的逻辑,通过浏览器的API实现,比如在请求服务器端API时,需要把客户端证书和私钥一起发送到服务器端,然后根据服务器端返回的证书进行验证。
  4. 如果证书验证通过,建立安全通信通道进行数据传输。

需要注意的是,在前端代码中添加证书验证的逻辑时,需要注意证书的安全性和有效期,以及证书的更新和维护等问题。

前端携带证书发请求

证书放到请求头

在发请求的时候,前端需要使用XMLHttpRequest或Fetch API来发送请求。
其中,XMLHttpRequest可以使用setRequestHeader方法来设置请求头,Fetch API可以使用headers属性来设置请求头。
在设置请求头的时候,需要添加Authorization字段,并把证书和私钥通过Base64编码后放在该字段中。同时,还需要添加Content-Type字段,并指定为application/json,表示请求体的格式为JSON。

  1. 前端发送请求:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // 生成证书和私钥,并Base64编码
    const cert = 'xxxxxx'; // 客户端证书
    const key = 'xxxxxx'; // 客户端私钥
    const authorization = `Basic ${btoa(`${cert}:${key}`)}`;

    // 发送请求
    const url = 'https://example.com/api';
    const data = { name: 'John Doe' };
    fetch(url, {
    method: 'POST',
    headers: {
    'Content-Type': 'application/json',
    'Authorization': authorization
    },
    body: JSON.stringify(data)
    })
    .then(response => response.json())
    .then(data => console.log(data))
    .catch(error => console.error(error));
  2. 服务端需要在接收到请求时,从请求头中获取证书和私钥,并进行验证。可以使用Node.js中的tls模块来实现证书验证。
    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
    const https = require('https');
    const fs = require('fs');
    const path = require('path');

    const server = https.createServer({
    key: fs.readFileSync(path.join(__dirname, 'server-key.pem')),
    cert: fs.readFileSync(path.join(__dirname, 'server-cert.pem')),
    requestCert: true,
    rejectUnauthorized: true
    }, (req, res) => {
    const cert = req.socket.getPeerCertificate();
    const authorized = req.socket.authorized;
    const error = req.socket.authorizationError;

    if (authorized) {
    // 验证证书的有效性
    // ...
    } else {
    console.error(error);
    res.statusCode = 401;
    res.end('Unauthorized');
    }
    });

    server.listen(443);

注意:在实际使用中,需要根据服务器端的要求来设置Authorization字段的格式和内容。同时,还需要对证书和私钥进行保护,避免泄露和被恶意利用。

缺点:按照这种做法,证书会暴露在静态代码中,这种行为会泄露我们的证书和私钥,在前端静态代码中直接暴露证书和私钥是非常不安全的,容易被攻击者窃取。

浏览器客户端证书存储机制

我们可以考虑使用浏览器提供的客户端证书存储机制。这种机制可以让浏览器管理我们的证书和私钥,并在需要的时候自动调用。

  1. 把客户端证书导入到浏览器的证书存储中。这个过程因浏览器不同而异,一般来说,我们可以在浏览器的设置中找到证书管理工具,然后选择“导入证书”功能,把客户端证书导入到证书存储中。

  2. 在前端代码中添加证书验证的逻辑,通过浏览器的API实现。我们可以使用XMLHttpRequest或Fetch API来发送请求,并在请求中添加证书的标识。浏览器会自动调用证书存储中的证书和私钥,进行双向认证。
    您说的很对,在前端静态代码中直接暴露证书和私钥是非常不安全的,容易被攻击者窃取。为了避免这种情况,我们可以考虑使用浏览器提供的客户端证书存储机制。这种机制可以让浏览器管理我们的证书和私钥,并在需要的时候自动调用。下面是具体的步骤:

  3. 生成客户端证书和私钥,可以使用openssl命令或其他工具生成,注意证书需要符合服务器端的要求。

  4. 把客户端证书上传到服务器端,并在服务器端添加到信任列表中。

  5. 把客户端证书导入到浏览器的证书存储中。这个过程因浏览器不同而异,一般来说,我们可以在浏览器的设置中找到证书管理工具,然后选择“导入证书”功能,把客户端证书导入到证书存储中。

  6. 在前端代码中添加证书验证的逻辑,通过浏览器的API实现。我们可以使用XMLHttpRequest或Fetch API来发送请求,并在请求中添加证书的标识。浏览器会自动调用证书存储中的证书和私钥,进行双向认证。

以下是示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 发送请求
const url = 'https://example.com/api';
const data = { name: 'John Doe' };
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
agent: new https.Agent({
cert: certBuffer,
key: keyBuffer,
passphrase: 'passphrase'
})
})
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error(error));

缺点:这种方法还需要让使用用户手动导入证书,会增加软件使用成本,这会影响用户体验。另外,如果每个用户都需要手动导入证书,那管理起来也会非常麻烦。

前端动态生成证书

在客户端动态生成证书和私钥,并在发送请求时把证书和私钥一起发送给服务器端。

  1. 在前端代码中使用crypto API动态生成客户端证书和私钥。可以使用以下代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    const { privateKey, publicKey } = await crypto.subtle.generateKey(
    {
    name: "RSASSA-PKCS1-v1_5",
    modulusLength: 2048,
    publicExponent: new Uint8Array([1, 0, 1]),
    hash: { name: "SHA-256" },
    },
    true,
    ["sign", "verify"]
    );

    const cert = await crypto.subtle.exportKey("spki", publicKey);
    const key = await crypto.subtle.exportKey("pkcs8", privateKey);

    const certPEM = `-----BEGIN CERTIFICATE-----\n${btoa(String.fromCharCode(...new Uint8Array(cert)))}\n-----END CERTIFICATE-----`;
    const keyPEM = `-----BEGIN PRIVATE KEY-----\n${btoa(String.fromCharCode(...new Uint8Array(key)))}\n-----END PRIVATE KEY-----`;
  2. 在发送请求时把证书和私钥一起发送到服务器端。可以使用XMLHttpRequest或Fetch API来发送请求,并把证书和私钥放在请求头中。以下是示例代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // 发送请求
    const url = 'https://example.com/api';
    const data = { name: 'John Doe' };
    fetch(url, {
    method: 'POST',
    headers: {
    'Content-Type': 'application/json',
    'X-Client-Cert': certPEM,
    'X-Client-Key': keyPEM
    },
    body: JSON.stringify(data)
    })
    .then(response => response.json())
    .then(data => console.log(data))
    .catch(error => console.error(error));
  3. 在服务器端进行证书的验证。服务器端需要把客户端证书添加到信任列表中,并在接收请求时验证证书的有效性。可以使用Node.js中的tls模块来实现证书验证。以下是示例代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    const https = require('https');
    const fs = require('fs');

    const options = {
    key: fs.readFileSync('server-key.pem'),
    cert: fs.readFileSync('server-cert.pem'),
    ca: fs.readFileSync('client-cert.pem'),
    requestCert: true,
    rejectUnauthorized: true
    };

    https.createServer(options, (req, res) => {
    const certPEM = req.socket.getPeerCertificate().raw.toString('base64');
    // 验证证书的有效性
    // ...
    }).listen(443);

问题:后端本来已经根据申请好的客户端证书写好了一套逻辑,如果前端改为用动态生成证书的方式,那么后端需要重新开发。

nginx代理

可以通过nginx来实现这个功能,在nginx中开启双向认证。
在nginx配置文件中添加以下内容,用于指定客户端证书和私钥的路径和密码:

1
2
3
ssl_client_certificate /path/to/client/cert.pem;
ssl_client_key /path/to/client/key.pem;
ssl_password_file /path/to/client/password.txt;

其中,/path/to/client/cert.pem/path/to/client/key.pem分别为客户端证书和私钥的路径,/path/to/client/password.txt为密钥库密码的文件路径。

在nginx配置文件中添加以下内容,用于启用双向认证:

1
2
ssl_verify_client on;
ssl_verify_depth 2;

其中,ssl_verify_client on表示启用双向认证,ssl_verify_depth 2表示验证证书链的深度。
重启nginx服务,使配置生效。

当客户端发起请求时,nginx会要求客户端提供证书,如果客户端没有提供证书或者证书验证失败,则请求将被拒绝。同时,由于证书和私钥的路径和密码并没有写在代码中,因此也提高了证书和私钥的安全性。

问题:小程序是打包到 微信平台/飞书平台 发布的,开发人员没有办法在平台服务器中配置nginx的相关文件。

nginx+再找个域名做中转

业务接口后端为test.com,中转域名为auth.com。

  • auth.com和test.com实现nginx的双向认证。
  • 先从小程序代理到auth.com,这里部分接口做权限验证。通过后才可以调用业务接口。

小程序代理的样例代码:

1
2
3
4
5
6
7
8
9
10
wx.request({
url: 'http://your-proxy-server.com/your-api-endpoint',
header: {
'Proxy-Host': 'your-backend-server.com',
'Proxy-Port': 'your-backend-port'
},
success(res) {
console.log(res.data)
}
})