认证方式有很多,比如使用用户名密码的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 }
|
这里 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(), }, }
|
这个过程主要需要配置是客户端,不过也很简单:
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(), }, }, }
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) { c, err := tls.Dial(network, addr, YOUR_TLS_CONFIG) if err != nil { return nil, err } 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,可以参考这个项目。