package auth import ( "context" "crypto/rand" "crypto/rsa" "encoding/json" "net" "net/http" "testing" "time" "github.com/MicahParks/jwkset" "github.com/bluenviron/mediamtx/internal/conf" "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/require" ) func mustParseCIDR(v string) net.IPNet { _, ne, err := net.ParseCIDR(v) if err != nil { panic(err) } if ipv4 := ne.IP.To4(); ipv4 != nil { return net.IPNet{IP: ipv4, Mask: ne.Mask[len(ne.Mask)-4 : len(ne.Mask)]} } return *ne } func TestAuthInternal(t *testing.T) { for _, outcome := range []string{ "ok", "wrong user", "wrong pass", "wrong ip", "wrong action", "wrong path", } { for _, encryption := range []string{ "plain", "sha256", "argon2", } { t.Run(outcome+" "+encryption, func(t *testing.T) { m := Manager{ Method: conf.AuthMethodInternal, InternalUsers: []conf.AuthInternalUser{ { IPs: conf.IPNetworks{mustParseCIDR("127.1.1.1/32")}, Permissions: []conf.AuthInternalUserPermission{{ Action: conf.AuthActionPublish, Path: "mypath", }}, }, }, } switch encryption { case "plain": m.InternalUsers[0].User = conf.Credential("testuser") m.InternalUsers[0].Pass = conf.Credential("testpass") case "sha256": m.InternalUsers[0].User = conf.Credential("sha256:rl3rgi4NcZkpAEcacZnQ2VuOfJ0FxAqCRaKB/SwdZoQ=") m.InternalUsers[0].Pass = conf.Credential("sha256:E9JJ8stBJ7QM+nV4ZoUCeHk/gU3tPFh/5YieiJp6n2w=") case "argon2": m.InternalUsers[0].User = conf.Credential( "argon2:$argon2id$v=19$m=4096,t=3,p=1$MTIzNDU2Nzg$Ux/LWeTgJQPyfMMJo1myR64+o8rALHoPmlE1i/TR+58") m.InternalUsers[0].Pass = conf.Credential( "argon2:$argon2i$v=19$m=4096,t=3,p=1$MTIzNDU2Nzg$/mrZ42TiTv1mcPnpMUera5oi0SFYbbyueAbdx5sUvWo") } var req *Request switch outcome { case "ok": req = &Request{ Action: conf.AuthActionPublish, Path: "mypath", Credentials: &Credentials{ User: "testuser", Pass: "testpass", }, IP: net.ParseIP("127.1.1.1"), } case "wrong user": req = &Request{ Action: conf.AuthActionPublish, Path: "mypath", Credentials: &Credentials{ User: "wrong", Pass: "testpass", }, IP: net.ParseIP("127.1.1.1"), } case "wrong pass": req = &Request{ Action: conf.AuthActionPublish, Path: "mypath", Credentials: &Credentials{ User: "testuser", Pass: "wrong", }, IP: net.ParseIP("127.1.1.1"), } case "wrong ip": req = &Request{ Action: conf.AuthActionPublish, Path: "mypath", Credentials: &Credentials{ User: "testuser", Pass: "testpass", }, IP: net.ParseIP("127.1.1.2"), } case "wrong action": req = &Request{ Action: conf.AuthActionRead, Path: "mypath", Credentials: &Credentials{ User: "testuser", Pass: "testpass", }, IP: net.ParseIP("127.1.1.1"), } case "wrong path": req = &Request{ Action: conf.AuthActionPublish, Path: "wrong", Credentials: &Credentials{ User: "testuser", Pass: "testpass", }, IP: net.ParseIP("127.1.1.1"), } } // first request with empty credentials err := m.Authenticate(&Request{ Action: req.Action, Path: req.Path, Credentials: &Credentials{}, IP: req.IP, }) require.Equal(t, &Error{ Wrapped: err.Wrapped, AskCredentials: true, }, err) // second request err = m.Authenticate(req) if outcome == "ok" { require.Nil(t, err) } else { require.EqualError(t, err.Wrapped, "authentication failed") require.False(t, err.AskCredentials) } }) } } } func TestAuthInternalCustomVerifyFunc(t *testing.T) { for _, ca := range []string{"ok", "invalid"} { t.Run(ca, func(t *testing.T) { m := Manager{ Method: conf.AuthMethodInternal, InternalUsers: []conf.AuthInternalUser{ { User: "myuser", Pass: "mypass", IPs: conf.IPNetworks{mustParseCIDR("127.1.1.1/32")}, Permissions: []conf.AuthInternalUserPermission{{ Action: conf.AuthActionPublish, Path: "mypath", }}, }, }, } req1 := &Request{ Action: conf.AuthActionPublish, Path: "mypath", Credentials: &Credentials{}, IP: net.ParseIP("127.1.1.1"), CustomVerifyFunc: func(expectedUser, expectedPass string) bool { require.Equal(t, "myuser", expectedUser) require.Equal(t, "mypass", expectedPass) return (ca == "ok") }, } err := m.Authenticate(req1) if ca == "ok" { require.Nil(t, err) } else { require.EqualError(t, err.Wrapped, "authentication failed") } }) } } func TestAuthHTTP(t *testing.T) { for _, outcome := range []string{"ok", "fail"} { t.Run(outcome, func(t *testing.T) { firstReceived := false 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.IP != "127.0.0.1" || in.User != "testpublisher" || in.Password != "testpass" || in.Path != "teststream" || in.Protocol != "rtsp" || (firstReceived && in.ID == "") || in.Action != "publish" || (in.Query != "user=testreader&pass=testpass¶m=value" && in.Query != "user=testpublisher&pass=testpass¶m=value" && in.Query != "param=value") { w.WriteHeader(http.StatusBadRequest) return } firstReceived = true }), } ln, err := net.Listen("tcp", "127.0.0.1:9120") require.NoError(t, err) go httpServ.Serve(ln) defer httpServ.Shutdown(context.Background()) m := Manager{ Method: conf.AuthMethodHTTP, HTTPAddress: "http://127.0.0.1:9120/auth", } var req *Request if outcome == "ok" { req = &Request{ Action: conf.AuthActionPublish, Path: "teststream", Query: "param=value", Protocol: ProtocolRTSP, Credentials: &Credentials{ User: "testpublisher", Pass: "testpass", }, IP: net.ParseIP("127.0.0.1"), } } else { req = &Request{ Action: conf.AuthActionPublish, Path: "teststream", Query: "param=value", Protocol: ProtocolRTSP, Credentials: &Credentials{ User: "invalid", Pass: "testpass", }, IP: net.ParseIP("127.0.0.1"), } } // first request with empty credentials err2 := m.Authenticate(&Request{ Action: req.Action, Path: req.Path, Credentials: &Credentials{}, IP: req.IP, }) require.Equal(t, &Error{ Wrapped: err2.Wrapped, AskCredentials: true, }, err2) // second request err2 = m.Authenticate(req) if outcome == "ok" { require.Nil(t, err2) } else { require.EqualError(t, err2.Wrapped, "server replied with code 400") require.False(t, err2.AskCredentials) } }) } } func TestAuthHTTPExclude(t *testing.T) { m := Manager{ Method: conf.AuthMethodHTTP, HTTPAddress: "http://not-to-be-used:9120/auth", HTTPExclude: []conf.AuthInternalUserPermission{{ Action: conf.AuthActionPublish, }}, } err := m.Authenticate(&Request{ Action: conf.AuthActionPublish, Path: "teststream", Query: "param=value", Protocol: ProtocolRTSP, Credentials: &Credentials{ User: "", Pass: "", }, IP: net.ParseIP("127.0.0.1"), }) require.Nil(t, err) } func TestAuthJWT(t *testing.T) { for _, ca := range []string{"object", "string"} { t.Run(ca, func(t *testing.T) { // reference: // https://github.com/MicahParks/jwkset/blob/master/examples/http_server/main.go 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:4567") require.NoError(t, err) go httpServ.Serve(ln) defer httpServ.Shutdown(context.Background()) var req *Request if ca == "object" { type customClaims struct { jwt.RegisteredClaims MediaMTXPermissions string `json:"my_permission_key"` } var enc []byte enc, err = json.Marshal([]conf.AuthInternalUserPermission{{ Action: conf.AuthActionPublish, Path: "mypath", }}) require.NoError(t, err) 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: string(enc), } token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) token.Header[jwkset.HeaderKID] = "test-key-id" var ss string ss, err = token.SignedString(key) require.NoError(t, err) req = &Request{ Action: conf.AuthActionPublish, Path: "mypath", Query: "param=value", Protocol: ProtocolRTSP, Credentials: &Credentials{ Token: ss, }, IP: net.ParseIP("127.0.0.1"), } } else { 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" var ss string ss, err = token.SignedString(key) require.NoError(t, err) req = &Request{ Action: conf.AuthActionPublish, Path: "mypath", Protocol: ProtocolWebRTC, Credentials: &Credentials{ Token: ss, }, IP: net.ParseIP("127.0.0.1"), } } m := Manager{ Method: conf.AuthMethodJWT, JWTJWKS: "http://localhost:4567/jwks", JWTClaimKey: "my_permission_key", } // first request with empty credentials err2 := m.Authenticate(&Request{ Action: req.Action, Path: req.Path, Credentials: &Credentials{}, IP: req.IP, }) require.Equal(t, &Error{ Wrapped: err2.Wrapped, AskCredentials: true, }, err2) // second request err2 = m.Authenticate(req) require.Nil(t, err2) }) } } func TestAuthJWTExclude(t *testing.T) { m := Manager{ Method: conf.AuthMethodJWT, JWTJWKS: "http://localhost:4567/jwks", JWTClaimKey: "my_permission_key", JWTExclude: []conf.AuthInternalUserPermission{{ Action: conf.AuthActionPublish, }}, } err := m.Authenticate(&Request{ Action: conf.AuthActionPublish, Path: "teststream", Query: "param=value", Protocol: ProtocolRTSP, IP: net.ParseIP("127.0.0.1"), }) require.Nil(t, err) } func TestAuthJWTRefresh(t *testing.T) { // reference: // https://github.com/MicahParks/jwkset/blob/master/examples/http_server/main.go var key *rsa.PrivateKey httpServ := &http.Server{ Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { jwk, err := jwkset.NewJWKFromKey(key, jwkset.JWKOptions{ Metadata: jwkset.JWKMetadataOptions{ KID: "test-key-id", }, }) require.NoError(t, err) jwkSet := jwkset.NewMemoryStorage() err = jwkSet.KeyWrite(context.Background(), jwk) require.NoError(t, err) 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:4567") require.NoError(t, err) go httpServ.Serve(ln) defer httpServ.Shutdown(context.Background()) m := Manager{ Method: conf.AuthMethodJWT, JWTJWKS: "http://localhost:4567/jwks", JWTClaimKey: "my_permission_key", } for i := 0; i < 2; i++ { key, err = rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err) 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" var ss string ss, err = token.SignedString(key) require.NoError(t, err) err2 := m.Authenticate(&Request{ Action: conf.AuthActionPublish, Path: "mypath", Query: "param=value", Protocol: ProtocolRTSP, Credentials: &Credentials{ Token: ss, }, IP: net.ParseIP("127.0.0.1"), }) require.Nil(t, err2) m.RefreshJWTJWKS() } }