feat(handler): forward mTLS client cert identity to auth plugins

Extracts the verified mTLS peer certificate (CN, SANs, SHA-256
fingerprint) from the TLS connection in the HTTP handler after the
handshake completes, stores it in the request context, and forwards
it to both gRPC and HTTP auth plugins via new AuthenticateRequest
fields (client_cn, client_san, client_cert_fingerprint).
This commit is contained in:
ginuerzh
2026-07-04 17:30:36 +08:00
parent 4bc3886825
commit 0ca1e7621a
5 changed files with 131 additions and 11 deletions
+12 -7
View File
@@ -60,13 +60,18 @@ func (p *grpcPlugin) Authenticate(ctx context.Context, user, password string, op
if v := xctx.SrcAddrFromContext(ctx); v != nil {
clientAddr = v.String()
}
r, err := p.client.Authenticate(ctx,
&proto.AuthenticateRequest{
Service: options.Service,
Username: user,
Password: password,
Client: clientAddr,
})
req := &proto.AuthenticateRequest{
Service: options.Service,
Username: user,
Password: password,
Client: clientAddr,
}
if v := xctx.PeerCertFromContext(ctx); v != nil {
req.ClientCn = v.CN
req.ClientSan = v.SANs
req.ClientCertFingerprint = v.Fingerprint
}
r, err := p.client.Authenticate(ctx, req)
if err != nil {
p.log.Error(err)
return "", false
+12 -4
View File
@@ -14,10 +14,13 @@ import (
)
type httpPluginRequest struct {
Service string `json:"service"`
Username string `json:"username"`
Password string `json:"password"`
Client string `json:"client"`
Service string `json:"service"`
Username string `json:"username"`
Password string `json:"password"`
Client string `json:"client"`
ClientCn string `json:"clientCn,omitempty"`
ClientSan []string `json:"clientSan,omitempty"`
ClientCertFingerprint string `json:"clientCertFingerprint,omitempty"`
}
type httpPluginResponse struct {
@@ -71,6 +74,11 @@ func (p *httpPlugin) Authenticate(ctx context.Context, user, password string, op
Password: password,
Client: clientAddr,
}
if v := xctx.PeerCertFromContext(ctx); v != nil {
rb.ClientCn = v.CN
rb.ClientSan = v.SANs
rb.ClientCertFingerprint = v.Fingerprint
}
v, err := json.Marshal(&rb)
if err != nil {
return
+20
View File
@@ -101,6 +101,26 @@ func ClientIDFromContext(ctx context.Context) ClientID {
return v
}
type peerCertKey struct{}
// PeerCert carries the verified mTLS client certificate identity.
type PeerCert struct {
CN string
SANs []string
Fingerprint string // SHA-256 hex of cert.Raw
}
// ContextWithPeerCert returns a copy of ctx that carries the peer cert.
func ContextWithPeerCert(ctx context.Context, cert *PeerCert) context.Context {
return context.WithValue(ctx, peerCertKey{}, cert)
}
// PeerCertFromContext returns the peer cert stored in ctx, or nil.
func PeerCertFromContext(ctx context.Context) *PeerCert {
v, _ := ctx.Value(peerCertKey{}).(*PeerCert)
return v
}
// labelsKey saves the static service labels.
type labelsKey struct{}
+43
View File
@@ -150,6 +150,49 @@ func TestClientIDFromContext_WrongType(t *testing.T) {
}
}
func TestPeerCertContext(t *testing.T) {
cert := &PeerCert{
CN: "client.example.com",
SANs: []string{"san1.example.com", "san2.example.com"},
Fingerprint: "abcdef1234567890",
}
ctx := ContextWithPeerCert(context.Background(), cert)
got := PeerCertFromContext(ctx)
if got == nil {
t.Fatal("PeerCertFromContext() = nil, want non-nil")
}
if got.CN != cert.CN {
t.Errorf("PeerCertFromContext().CN = %q, want %q", got.CN, cert.CN)
}
if len(got.SANs) != len(cert.SANs) || got.SANs[0] != cert.SANs[0] {
t.Errorf("PeerCertFromContext().SANs = %v, want %v", got.SANs, cert.SANs)
}
if got.Fingerprint != cert.Fingerprint {
t.Errorf("PeerCertFromContext().Fingerprint = %q, want %q", got.Fingerprint, cert.Fingerprint)
}
}
func TestPeerCertFromContext_Empty(t *testing.T) {
if got := PeerCertFromContext(context.Background()); got != nil {
t.Errorf("PeerCertFromContext(empty) = %v, want nil", got)
}
}
func TestPeerCertFromContext_WrongType(t *testing.T) {
ctx := context.WithValue(context.Background(), peerCertKey{}, "not-a-cert")
if got := PeerCertFromContext(ctx); got != nil {
t.Errorf("PeerCertFromContext(wrong type) = %v, want nil", got)
}
}
func TestPeerCertFromContext_Nil(t *testing.T) {
ctx := ContextWithPeerCert(context.Background(), nil)
if got := PeerCertFromContext(ctx); got != nil {
t.Errorf("PeerCertFromContext(nil) = %v, want nil", got)
}
}
func TestContextWithLabels(t *testing.T) {
labels := map[string]string{"tenant": "acme", "region": "eu"}
ctx := ContextWithLabels(context.Background(), labels)
+44
View File
@@ -190,6 +190,9 @@ package http
import (
"bufio"
"context"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"errors"
"net"
"net/http"
@@ -353,6 +356,9 @@ func (h *httpHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler
})
log.Infof("%s <> %s", conn.RemoteAddr(), conn.LocalAddr())
// Save the raw conn before stats wrapping to preserve TLS metadata.
rawConn := conn
pStats := xstats.Stats{}
conn = stats_wrapper.WrapConn(conn, &pStats)
@@ -386,6 +392,11 @@ func (h *httpHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler
}
defer req.Body.Close()
// Extract mTLS peer certificate from the raw connection.
if peerCert := getTLSPeerCert(rawConn); peerCert != nil {
ctx = xctx.ContextWithPeerCert(ctx, peerCert)
}
if clientIP := xhttp.GetClientIP(req); clientIP != nil {
ro.ClientIP = clientIP.String()
}
@@ -546,6 +557,39 @@ func buildHTTPRecorder(req *http.Request) *xrecorder.HTTPRecorderObject {
}
}
// getTLSPeerCert extracts the verified mTLS client certificate identity from
// a connection by walking wrapper layers (traffic limiter, etc.) to reach the
// underlying *tls.Conn, then reading ConnectionState().VerifiedChains.
// It returns nil if the connection has no TLS peer certificate.
func getTLSPeerCert(conn net.Conn) *xctx.PeerCert {
for {
if tc, ok := conn.(interface{ ConnectionState() tls.ConnectionState }); ok {
cs := tc.ConnectionState()
if cs.HandshakeComplete && len(cs.VerifiedChains) > 0 && len(cs.VerifiedChains[0]) > 0 {
cert := cs.VerifiedChains[0][0]
fpr := sha256.Sum256(cert.Raw)
sans := make([]string, 0, len(cert.DNSNames)+len(cert.EmailAddresses)+len(cert.URIs))
sans = append(sans, cert.DNSNames...)
sans = append(sans, cert.EmailAddresses...)
for _, u := range cert.URIs {
sans = append(sans, u.String())
}
return &xctx.PeerCert{
CN: cert.Subject.CommonName,
SANs: sans,
Fingerprint: hex.EncodeToString(fpr[:]),
}
}
return nil
}
if uw, ok := conn.(interface{ UnwrapConn() net.Conn }); ok {
conn = uw.UnwrapConn()
continue
}
return nil
}
}
// observeStats periodically publishes per-client traffic stats to the
// configured Observer. It runs in a background goroutine started by Init.
// Events that fail to send are retried on the next tick.