基于x509的认证授权技术

Sep 22 2020 crypto

认证方式有很多,比如使用用户名密码的BasicAuth,使用 AccessToken 的 OAuth 2.0 等等,还有一个这篇文章要写的基于 X509 的认证方式,这个不太常见,目前我就在k8s api server中见到。

认证的本质就是获取并确定请求方的身份。回想一下在 TLS 中确认身份的情况,在握手中服务端要返回给客户端证书,客户端要检验服务端提供的证书合法性,校验通过后再进行通信。

大多数的使用场景都是客户端检验服务端证书,但是有没有服务端要求校验客户端证书的?有,并且是TLS 标准,所以基于此我们就可以实现基于 X509 的认证方式。

这就比一般的 TLS 握手多了一个过程,服务端要求客户端必须提供证书并进行校验,校验通过再进行之后的握手,这个互相认证的流程也叫做 mTLS。

在 x509 v3 证书中有一个 ExtKeyUsage 字段,是一个数组,按照最小授权权限原则,对于 Server 而言,这里可以选择服务端认证,而 Client 选择客户端认证即可。

1
2
3
4
5
6
const (
ExtKeyUsageAny ExtKeyUsage = iota
ExtKeyUsageServerAuth // 服务端认证
ExtKeyUsageClientAuth // 客户端认证
// .... 这里省略其它扩展选项
)

这种方式在内网中使用极为方便,如果我们要求访问认证有过期时间,那么也不需要在数据库系统中记录过期时间,只要颁发的证书设置 NotAfter 字段即可。

至此,我们保证通信两端都是信任CA颁发的。不过还需要获取证书端的具体身份信息,这个在证书内也有提供。

证书内提供了国家、地区、组织、通用名称等字段,这个就可以用作授权的身份信息。

1
2
3
4
5
6
7
8
9
type Name struct {
Country, Organization, OrganizationalUnit []string
Locality, Province []string
StreetAddress, PostalCode []string
SerialNumber, CommonName string

Names []AttributeTypeAndValue
ExtraNames []AttributeTypeAndValue // Go 1.5
}

这里 CommonName 通用名称可以视作用户身份标识符,Organization 组织名称可以视作用户组。通常情况使用这两个字段进行授权操作就足够了,一般很多场景都只需要使用 CommonName 就可以了。

这就要求颁发证书需要保证这两个字段的正确性,以及通用名称字段的唯一性。所以如果是高安全等级的场景可以在证书颁发的时候加入人工审核环节。

下面使用 Go 实现双向认证,服务端需要配置信任CA和要求客户端认证即可:

1
2
3
4
5
6
7
server := http.Server{
TLSConfig: &tls.Config{
ClientAuth: tls.RequireAndVerifyClientCert, // 客户端必须要提供证书
Certificates: []tls.Certificate{}, // 服务端证书
ClientCAs: x509.NewCertPool(), // 校验客户端证书的CA集合
},
}

这个过程主要需要配置是客户端,不过也很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
httpclient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
Certificates: []tls.Certificate{}, // 客户端证书
RootCAs: x509.NewCertPool(), // 校验服务端证书CA集合
},
},
}

req, _ := http.NewRequest(http.MethodGet, "https://localhost:8443/anycall", nil)
resp, err := httpclient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
_, _ = io.Copy(os.StdOut, resp.body)

这样通信过程认证过程就可以在底层 TLS 握手时进行,服务端应用层“可以不再”需要进行任何配置。

1
2
3
4
5
6
7
8
9
10
11
// 授权方式示例
http.HandleFunc("/anycall", func(w http.ResponseWriter, req *http.Request) {
commonName := req.TLS.PeerCertificates[0].Subject.CommonName
if commonName != "客户端通用名称" {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte("权限不足"))
return
}

_, _ = w.Write([]byte("get success"))
})

不过如果CA被恶意的重复颁发一个相同通用名称的证书,就会造成服务端错误的识别证书,不过可以用证书指纹判断是否与配置数据一致。

这样也造成了一定的麻烦,需要颁发证书就得修改。这个过程适用于特别特别注重安全的场景使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
http.HandleFunc("/anycall", func(w http.ResponseWriter, req *http.Request) {
clientCertificate := req.TLS.PeerCertificates[0]
commonName := clientCertificate.Subject.CommonName
if commonName != "客户端通用名称" {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte("权限不足"))
return
}

// 多加一次证书指纹判断,另外错误的颁发证书应该进行报警
hash := sha256.Sum256(clientCertificate.Raw)
if !hmac.Equal(hash[:], []byte("HASH_AT_PRE_CONFIG")) {
_, _ = w.Write([]byte("无法匹配证书"))
return
}

_, _ = w.Write([]byte("get success"))
})

当然客户端也可以验证服务端证书指纹,不过这个有个专有名称叫做 HTTP Public Key Pinning (HPKP)

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
httpclient := &http.Client{
Transport: &http.Transport{
Dial: func(network, addr string) (net.Conn, error) {
// 第三个参数 *tls.Cofnig 按照上文说明填写
c, err := tls.Dial(network, addr, YOUR_TLS_CONFIG)
if err != nil {
return nil, err
}

// 获取HPKP并校验,视情况可只验证第一个的证书,而不是所有的证书链中所有证书
var hasOne bool
for _, certificate := range c.ConnectionState().PeerCertificates {
hash := sha256.Sum256(certificate.Raw)
if hmac.Equal(hash[:], []byte(nil)) {
hasOne = true
break
}
}

if !hasOne {
return nil, errors.New("hpkp verifies failed")
}

return c, nil
},
},
}

更具体的授权操作可以根据 RBAC 形式进行,这个和传统流程一致,就不再赘述。

如果你正在使用 gRPC,我写了一个 go-example,可以参考这个项目