add authHTTPFingerprint (#5413) (#5422)

This commit is contained in:
Alessandro Ros
2026-02-05 16:51:29 +01:00
committed by GitHub
parent 4dc09e1d51
commit 4f859fb40b
7 changed files with 251 additions and 1 deletions
+2
View File
@@ -108,6 +108,8 @@ components:
$ref: '#/components/schemas/AuthInternalUser'
authHTTPAddress:
type: string
authHTTPFingerprint:
type: string
authHTTPExclude:
type: array
items:
+31
View File
@@ -121,6 +121,21 @@ authHTTPExclude:
- action: pprof
```
If the authentication server uses HTTPS and has a self-signed or invalid TLS certificate, you can provide the fingerprint of the certificate to validate it anyway:
```yml
authMethod: http
authHTTPAddress: https://myauthserver/auth
authHTTPFingerprint: 33949e05fffb5ff3e8aa16f8213a6251b4d9363804ba53233c4da9a46d6f2739
```
The fingerprint can be obtained with:
```sh
openssl s_client -connect myauthserver:443 </dev/null 2>/dev/null | sed -n '/BEGIN/,/END/p' > server.crt
openssl x509 -in server.crt -noout -fingerprint -sha256 | cut -d "=" -f2 | tr -d ':'
```
### External JWT provider
Authentication can be delegated to an external identity server, that is capable of generating JWTs and provides a JWKS endpoint. With respect to the HTTP-based method, this has the advantage that the external server is contacted once, and not for every request, greatly improving performance. In order to use the JWT-based authentication method, set `authMethod` and `authJWTJWKS`:
@@ -146,6 +161,22 @@ The JWT is expected to contain a claim, with a list of permissions in the same f
}
```
If the JWKS server uses TLS and has a self-signed or invalid TLS certificate, you can provide the fingerprint of the certificate to validate it anyway:
```yml
authMethod: jwt
authJWTJWKS: https://my_identity_server/jwks_endpoint
authJWTJWKSFingerprint: 33949e05fffb5ff3e8aa16f8213a6251b4d9363804ba53233c4da9a46d6f2739
authJWTClaimKey: mediamtx_permissions
```
The fingerprint can be obtained with:
```sh
openssl s_client -connect my_identity_server:443 </dev/null 2>/dev/null | sed -n '/BEGIN/,/END/p' > server.crt
openssl x509 -in server.crt -noout -fingerprint -sha256 | cut -d "=" -f2 | tr -d ':'
```
#### Keycloak setup
Here's a tutorial on how to setup the [Keycloak identity server](https://www.keycloak.org/) in order to provide JWTs.
+17 -1
View File
@@ -60,6 +60,7 @@ type Manager struct {
Method conf.AuthMethod
InternalUsers []conf.AuthInternalUser
HTTPAddress string
HTTPFingerprint string
HTTPExclude []conf.AuthInternalUserPermission
JWTJWKS string
JWTJWKSFingerprint string
@@ -172,7 +173,22 @@ func (m *Manager) authenticateHTTP(req *Request) error {
Query: req.Query,
})
res, err := http.Post(m.HTTPAddress, "application/json", bytes.NewReader(enc))
u, err := url.Parse(m.HTTPAddress)
if err != nil {
return err
}
tr := &http.Transport{
TLSClientConfig: tls.MakeConfig(u.Hostname(), m.HTTPFingerprint),
}
defer tr.CloseIdleConnections()
httpClient := &http.Client{
Timeout: m.ReadTimeout,
Transport: tr,
}
res, err := httpClient.Post(m.HTTPAddress, "application/json", bytes.NewReader(enc))
if err != nil {
return fmt.Errorf("HTTP request failed: %w", err)
}
+192
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"encoding/json"
"net"
"net/http"
@@ -16,6 +17,58 @@ import (
"github.com/stretchr/testify/require"
)
var testTLSCertPub = []byte(`-----BEGIN CERTIFICATE-----
MIIDazCCAlOgAwIBAgIUXw1hEC3LFpTsllv7D3ARJyEq7sIwDQYJKoZIhvcNAQEL
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMDEyMTMxNzQ0NThaFw0zMDEy
MTExNzQ0NThaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQDG8DyyS51810GsGwgWr5rjJK7OE1kTTLSNEEKax8Bj
zOyiaz8rA2JGl2VUEpi2UjDr9Cm7nd+YIEVs91IIBOb7LGqObBh1kGF3u5aZxLkv
NJE+HrLVvUhaDobK2NU+Wibqc/EI3DfUkt1rSINvv9flwTFu1qHeuLWhoySzDKEp
OzYxpFhwjVSokZIjT4Red3OtFz7gl2E6OAWe2qoh5CwLYVdMWtKR0Xuw3BkDPk9I
qkQKx3fqv97LPEzhyZYjDT5WvGrgZ1WDAN3booxXF3oA1H3GHQc4m/vcLatOtb8e
nI59gMQLEbnp08cl873bAuNuM95EZieXTHNbwUnq5iybAgMBAAGjUzBRMB0GA1Ud
DgQWBBQBKhJh8eWu0a4au9X/2fKhkFX2vjAfBgNVHSMEGDAWgBQBKhJh8eWu0a4a
u9X/2fKhkFX2vjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBj
3aCW0YPKukYgVK9cwN0IbVy/D0C1UPT4nupJcy/E0iC7MXPZ9D/SZxYQoAkdptdO
xfI+RXkpQZLdODNx9uvV+cHyZHZyjtE5ENu/i5Rer2cWI/mSLZm5lUQyx+0KZ2Yu
tEI1bsebDK30msa8QSTn0WidW9XhFnl3gRi4wRdimcQapOWYVs7ih+nAlSvng7NI
XpAyRs8PIEbpDDBMWnldrX4TP6EWYUi49gCp8OUDRREKX3l6Ls1vZ02F34yHIt/7
7IV/XSKG096bhW+icKBWV0IpcEsgTzPK1J1hMxgjhzIMxGboAeUU+kidthOob6Sd
XQxaORfgM//NzX9LhUPk
-----END CERTIFICATE-----
`)
var testTLSCertKey = []byte(`-----BEGIN RSA PRIVATE KEY-----
MIIEogIBAAKCAQEAxvA8skudfNdBrBsIFq+a4ySuzhNZE0y0jRBCmsfAY8zsoms/
KwNiRpdlVBKYtlIw6/Qpu53fmCBFbPdSCATm+yxqjmwYdZBhd7uWmcS5LzSRPh6y
1b1IWg6GytjVPlom6nPxCNw31JLda0iDb7/X5cExbtah3ri1oaMkswyhKTs2MaRY
cI1UqJGSI0+EXndzrRc+4JdhOjgFntqqIeQsC2FXTFrSkdF7sNwZAz5PSKpECsd3
6r/eyzxM4cmWIw0+Vrxq4GdVgwDd26KMVxd6ANR9xh0HOJv73C2rTrW/HpyOfYDE
CxG56dPHJfO92wLjbjPeRGYnl0xzW8FJ6uYsmwIDAQABAoIBACi0BKcyQ3HElSJC
kaAao+Uvnzh4yvPg8Nwf5JDIp/uDdTMyIEWLtrLczRWrjGVZYbsVROinP5VfnPTT
kYwkfKINj2u+gC6lsNuPnRuvHXikF8eO/mYvCTur1zZvsQnF5kp4GGwIqr+qoPUP
bB0UMndG1PdpoMryHe+JcrvTrLHDmCeH10TqOwMsQMLHYLkowvxwJWsmTY7/Qr5S
Wm3PPpOcW2i0uyPVuyuv4yD1368fqnqJ8QFsQp1K6QtYsNnJ71Hut1/IoxK/e6hj
5Z+byKtHVtmcLnABuoOT7BhleJNFBksX9sh83jid4tMBgci+zXNeGmgqo2EmaWAb
agQslkECgYEA8B1rzjOHVQx/vwSzDa4XOrpoHQRfyElrGNz9JVBvnoC7AorezBXQ
M9WTHQIFTGMjzD8pb+YJGi3gj93VN51r0SmJRxBaBRh1ZZI9kFiFzngYev8POgD3
ygmlS3kTHCNxCK/CJkB+/jMBgtPj5ygDpCWVcTSuWlQFphePkW7jaaECgYEA1Blz
ulqgAyJHZaqgcbcCsI2q6m527hVr9pjzNjIVmkwu38yS9RTCgdlbEVVDnS0hoifl
+jVMEGXjF3xjyMvL50BKbQUH+KAa+V4n1WGlnZOxX9TMny8MBjEuSX2+362vQ3BX
4vOlX00gvoc+sY+lrzvfx/OdPCHQGVYzoKCxhLsCgYA07HcviuIAV/HsO2/vyvhp
xF5gTu+BqNUHNOZDDDid+ge+Jre2yfQLCL8VPLXIQW3Jff53IH/PGl+NtjphuLvj
7UDJvgvpZZuymIojP6+2c3gJ3CASC9aR3JBnUzdoE1O9s2eaoMqc4scpe+SWtZYf
3vzSZ+cqF6zrD/Rf/M35IQKBgHTU4E6ShPm09CcoaeC5sp2WK8OevZw/6IyZi78a
r5Oiy18zzO97U/k6xVMy6F+38ILl/2Rn31JZDVJujniY6eSkIVsUHmPxrWoXV1HO
y++U32uuSFiXDcSLarfIsE992MEJLSAynbF1Rsgsr3gXbGiuToJRyxbIeVy7gwzD
94TpAoGAY4/PejWQj9psZfAhyk5dRGra++gYRQ/gK1IIc1g+Dd2/BxbT/RHr05GK
6vwrfjsoRyMWteC1SsNs/CurjfQ/jqCfHNP5XPvxgd5Ec8sRJIiV7V5RTuWJsPu1
+3K6cnKEyg+0ekYmLertRFIY6SwWmY1fyKgTvxudMcsBY7dC4xs=
-----END RSA PRIVATE KEY-----
`)
func mustParseCIDR(v string) conf.IPNetwork {
_, ne, err := net.ParseCIDR(v)
if err != nil {
@@ -309,6 +362,62 @@ func TestAuthHTTP(t *testing.T) {
}
}
func TestAuthHTTPFingerprint(t *testing.T) {
httpServ := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/auth", r.URL.Path)
var in struct {
IP string `json:"ip"`
User string `json:"user"`
Password string `json:"password"`
Path string `json:"path"`
Protocol string `json:"protocol"`
ID string `json:"id"`
Action string `json:"action"`
Query string `json:"query"`
}
err := json.NewDecoder(r.Body).Decode(&in)
require.NoError(t, err)
if in.User != "testuser" || in.Password != "testpass" {
w.WriteHeader(http.StatusBadRequest)
return
}
}),
}
ln, err := net.Listen("tcp", "localhost:9121")
require.NoError(t, err)
cert, err := tls.X509KeyPair(testTLSCertPub, testTLSCertKey)
require.NoError(t, err)
httpServ.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
go httpServ.ServeTLS(ln, "", "")
defer httpServ.Shutdown(context.Background())
m := Manager{
Method: conf.AuthMethodHTTP,
HTTPAddress: "https://localhost:9121/auth",
HTTPFingerprint: "33949e05fffb5ff3e8aa16f8213a6251b4d9363804ba53233c4da9a46d6f2739",
}
err2 := m.Authenticate(&Request{
Action: conf.AuthActionPublish,
Path: "teststream",
Protocol: ProtocolRTSP,
Credentials: &Credentials{
User: "testuser",
Pass: "testpass",
},
IP: net.ParseIP("127.0.0.1"),
})
require.Nil(t, err2)
}
func TestAuthHTTPExclude(t *testing.T) {
m := Manager{
Method: conf.AuthMethodHTTP,
@@ -584,3 +693,86 @@ func TestAuthJWTRefresh(t *testing.T) {
m.RefreshJWTJWKS()
}
}
func TestAuthJWTFingerprint(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err)
httpServ := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
jwk, err2 := jwkset.NewJWKFromKey(key, jwkset.JWKOptions{
Metadata: jwkset.JWKMetadataOptions{
KID: "test-key-id",
},
})
require.NoError(t, err2)
jwkSet := jwkset.NewMemoryStorage()
err2 = jwkSet.KeyWrite(context.Background(), jwk)
require.NoError(t, err2)
response, err2 := jwkSet.JSONPublic(r.Context())
if err2 != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(response)
}),
}
ln, err := net.Listen("tcp", "localhost:4568")
require.NoError(t, err)
cert, err := tls.X509KeyPair(testTLSCertPub, testTLSCertKey)
require.NoError(t, err)
httpServ.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
go httpServ.ServeTLS(ln, "", "")
defer httpServ.Shutdown(context.Background())
type customClaims struct {
jwt.RegisteredClaims
MediaMTXPermissions []conf.AuthInternalUserPermission `json:"my_permission_key"`
}
claims := customClaims{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "test",
Subject: "somebody",
ID: "1",
},
MediaMTXPermissions: []conf.AuthInternalUserPermission{{
Action: conf.AuthActionPublish,
Path: "mypath",
}},
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header[jwkset.HeaderKID] = "test-key-id"
ss, err := token.SignedString(key)
require.NoError(t, err)
m := Manager{
Method: conf.AuthMethodJWT,
JWTJWKS: "https://localhost:4568/jwks",
JWTJWKSFingerprint: "33949e05fffb5ff3e8aa16f8213a6251b4d9363804ba53233c4da9a46d6f2739",
JWTClaimKey: "my_permission_key",
}
err2 := m.Authenticate(&Request{
Action: conf.AuthActionPublish,
Path: "mypath",
Protocol: ProtocolRTSP,
Credentials: &Credentials{
Token: ss,
},
IP: net.ParseIP("127.0.0.1"),
})
require.Nil(t, err2)
}
+1
View File
@@ -262,6 +262,7 @@ type Conf struct {
AuthInternalUsers []AuthInternalUser `json:"authInternalUsers"`
AuthHTTPAddress string `json:"authHTTPAddress"`
ExternalAuthenticationURL *string `json:"externalAuthenticationURL,omitempty"` // deprecated
AuthHTTPFingerprint string `json:"authHTTPFingerprint"`
AuthHTTPExclude []AuthInternalUserPermission `json:"authHTTPExclude"`
AuthJWTJWKS string `json:"authJWTJWKS"`
AuthJWTJWKSFingerprint string `json:"authJWTJWKSFingerprint"`
+2
View File
@@ -337,6 +337,7 @@ func (p *Core) createResources(initial bool) error {
Method: p.conf.AuthMethod,
InternalUsers: p.conf.AuthInternalUsers,
HTTPAddress: p.conf.AuthHTTPAddress,
HTTPFingerprint: p.conf.AuthHTTPFingerprint,
HTTPExclude: p.conf.AuthHTTPExclude,
JWTJWKS: p.conf.AuthJWTJWKS,
JWTJWKSFingerprint: p.conf.AuthJWTJWKSFingerprint,
@@ -719,6 +720,7 @@ func (p *Core) closeResources(newConf *conf.Conf, calledByAPI bool) {
closeAuthManager := newConf == nil ||
newConf.AuthMethod != p.conf.AuthMethod ||
newConf.AuthHTTPAddress != p.conf.AuthHTTPAddress ||
newConf.AuthHTTPFingerprint != p.conf.AuthHTTPFingerprint ||
!reflect.DeepEqual(newConf.AuthHTTPExclude, p.conf.AuthHTTPExclude) ||
newConf.AuthJWTJWKS != p.conf.AuthJWTJWKS ||
newConf.AuthJWTJWKSFingerprint != p.conf.AuthJWTJWKSFingerprint ||
+6
View File
@@ -107,6 +107,12 @@ authInternalUsers:
# If the response code is 20x, authentication is accepted, otherwise
# it is discarded.
authHTTPAddress:
# If the HTTP authentication URL has a self-signed or invalid certificate,
# you can provide the fingerprint of the certificate in order to
# validate it anyway. It can be obtained by running:
# openssl s_client -connect auth_http_domain:443 </dev/null 2>/dev/null | sed -n '/BEGIN/,/END/p' > server.crt
# openssl x509 -in server.crt -noout -fingerprint -sha256 | cut -d "=" -f2 | tr -d ':'
authHTTPFingerprint:
# Actions to exclude from HTTP-based authentication.
# Format is the same as the one of user permissions.
authHTTPExclude: