diff --git a/README.md b/README.md index 4f93afe75237..d735251402ba 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ And can be recorded and played back with: * Serve multiple streams at once in separate paths * Record streams to disk * Playback recorded streams -* Authenticate users; use internal or external authentication +* Authenticate users * Redirect readers to other RTSP servers (load balancing) * Control the server through the Control API * Reload the configuration without disconnecting existing clients (hot reloading) @@ -113,6 +113,9 @@ _rtsp-simple-server_ has been rebranded as _MediaMTX_. The reason is pretty obvi * [Other features](#other-features) * [Configuration](#configuration) * [Authentication](#authentication) + * [Internal](#internal) + * [HTTP-based](#http-based) + * [JWT-based](#jwt-based) * [Encrypt the configuration](#encrypt-the-configuration) * [Remuxing, re-encoding, compression](#remuxing-re-encoding-compression) * [Record streams to disk](#record-streams-to-disk) @@ -1028,31 +1031,44 @@ There are 3 ways to change the configuration: ### Authentication -Edit `mediamtx.yml` and set `publishUser` and `publishPass`: +#### Internal -```yml -pathDefaults: - publishUser: myuser - publishPass: mypass -``` +The server provides three way to authenticate users: +* Internal: users are stored in the configuration file +* HTTP-based: an external HTTP URL is contacted to perform authentication +* JWT: an external identity server provides authentication through JWTs -Only publishers that provide both username and password will be able to proceed: +The internal authentication method is the default one. Users are stored inside the configuration file, in this format: + +```yml +authInternalUsers: + # Username. 'any' means any user, including anonymous ones. +- user: any + # Password. Not used in case of 'any' user. + pass: + # IPs or networks allowed to use this user. An empty list means any IP. + ips: [] + # List of permissions. + permissions: + # Available actions are: publish, read, playback, api, metrics, pprof. + - action: publish + # Paths can be set to further restrict access to a specific path. + # An empty path means any path. + # Regular expressions can be used by using a tilde as prefix. + path: + - action: read + path: + - action: playback + path: +``` + +Only clients that provide username and passwords will be able to perform a given action: ``` ffmpeg -re -stream_loop -1 -i file.ts -c copy -f rtsp rtsp://myuser:mypass@localhost:8554/mystream ``` -It's possible to setup authentication for readers too: - -```yml -pathDefaults: - readUser: myuser - readPass: mypass -``` - -If storing plain credentials in the configuration file is a security problem, username and passwords can be stored as hashed strings. The Argon2 and SHA256 hashing algorithms are supported. - -To use Argon2, the string must be hashed using Argon2id (recommended) or Argon2i: +If storing plain credentials in the configuration file is a security problem, username and passwords can be stored as hashed strings. The Argon2 and SHA256 hashing algorithms are supported. To use Argon2, the string must be hashed using Argon2id (recommended) or Argon2i: ``` echo -n "mypass" | argon2 saltItWithSalt -id -l 32 -e @@ -1061,9 +1077,11 @@ echo -n "mypass" | argon2 saltItWithSalt -id -l 32 -e Then stored with the `argon2:` prefix: ```yml -pathDefaults: - readUser: argon2:$argon2id$v=19$m=4096,t=3,p=1$MTIzNDU2Nzg$OGGO0eCMN0ievb4YGSzvS/H+Vajx1pcbUmtLp2tRqRU - readPass: argon2:$argon2i$v=19$m=4096,t=3,p=1$MTIzNDU2Nzg$oct3kOiFywTdDdt19kT07hdvmsPTvt9zxAUho2DLqZw +authInternalUsers: +- user: argon2:$argon2id$v=19$m=4096,t=3,p=1$MTIzNDU2Nzg$OGGO0eCMN0ievb4YGSzvS/H+Vajx1pcbUmtLp2tRqRU + pass: argon2:$argon2i$v=19$m=4096,t=3,p=1$MTIzNDU2Nzg$oct3kOiFywTdDdt19kT07hdvmsPTvt9zxAUho2DLqZw + permissions: + - action: publish ``` To use SHA256, the string must be hashed with SHA256 and encoded with base64: @@ -1075,16 +1093,21 @@ echo -n "mypass" | openssl dgst -binary -sha256 | openssl base64 Then stored with the `sha256:` prefix: ```yml -pathDefaults: - readUser: sha256:j1tsRqDEw9xvq/D7/9tMx6Jh/jMhk3UfjwIB2f1zgMo= - readPass: sha256:BdSWkrdV+ZxFBLUQQY7+7uv9RmiSVA8nrPmjGjJtZQQ= +authInternalUsers: +- user: sha256:j1tsRqDEw9xvq/D7/9tMx6Jh/jMhk3UfjwIB2f1zgMo= + pass: sha256:BdSWkrdV+ZxFBLUQQY7+7uv9RmiSVA8nrPmjGjJtZQQ= + permissions: + - action: publish ``` **WARNING**: enable encryption or use a VPN to ensure that no one is intercepting the credentials in transit. +#### HTTP-based + Authentication can be delegated to an external HTTP server: ```yml +authMethod: http externalAuthenticationURL: http://myauthserver/auth ``` @@ -1092,20 +1115,18 @@ Each time a user needs to be authenticated, the specified URL will be requested ```json { - "ip": "ip", "user": "user", "password": "password", + "ip": "ip", + "action": "publish|read|playback|api|metrics|pprof", "path": "path", - "protocol": "rtsp|rtmp|hls|webrtc", + "protocol": "rtsp|rtmp|hls|webrtc|srt", "id": "id", - "action": "read|publish", "query": "query" } ``` -If the URL returns a status code that begins with `20` (i.e. `200`), authentication is successful, otherwise it fails. - -Please be aware that it's perfectly normal for the authentication server to receive requests with empty users and passwords, i.e.: +If the URL returns a status code that begins with `20` (i.e. `200`), authentication is successful, otherwise it fails. Be aware that it's perfectly normal for the authentication server to receive requests with empty users and passwords, i.e.: ```json { @@ -1114,7 +1135,87 @@ Please be aware that it's perfectly normal for the authentication server to rece } ``` -This happens because a RTSP client doesn't provide credentials until it is asked to. In order to receive the credentials, the authentication server must reply with status code `401`, then the client will send credentials. +This happens because RTSP clients don't provide credentials until they are asked to. In order to receive the credentials, the authentication server must reply with status code `401`, then the client will send credentials. + +Some actions can be excluded from the process: + +```yml +# Actions to exclude from HTTP-based authentication. +# Format is the same as the one of user permissions. +authHTTPExclude: +- action: api +- action: metrics +- action: pprof +``` + +#### JWT-based + +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 just once, and not for every request, greatly improving performance. In order to use the JWT-based authentication method, set `authMethod` and `authJWTJWKS`: + +```yml +authMethod: jwt +authJWTJWKS: http://my_identity_server/jwks_endpoint +``` + +The JWT is expected to contain the `mediamtx_permissions` scope, with a list of permissions in the same format as the one of user permissions: + +```json +{ + ... + "mediamtx_permissions": [ + { + "action": "publish", + "path": "" + } + ] +} +``` + +Clients are expected to pass the JWT in query parameters, for instance: + +``` +ffmpeg -re -stream_loop -1 -i file.ts -c copy -f rtsp rtsp://localhost:8554/mystream?jwt=MY_JWT +``` + +Here's a tutorial on how to setup the [Keycloak identity server](https://www.keycloak.org/) in order to provide such JWTs: + +1. Start Keycloak: + + ``` + docker run --rm -p 8080:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:23.0.7 start-dev + ``` + +2. Open the Keycloak administration console, click on "master" in the top left corner, "create realm", set realm name to `mediamtx`, Save + +3. Click on "Client scopes", "create client scope", set name to `mediamtx`, Save + +4. Tab "Mappers", "Configure a new Mapper", "User Attribute" + + * Name: `mediamtx_permissions` + * User Attribute: `mediamtx_permissions` + * Token Claim Name: `mediamtx_permissions` + * Claim JSON Type: `JSON` + * Multivalued: `On` + + Save + +5. Page "Clients", "Create client", set Client ID to `mediamtx`, Next, Client authentication `On`, Next, Save + +6. Open Tab "Credentials", copy client secret somewhere + +7. Tab "Client scopes", "Add client scope", Select `mediamtx`, Add, Default + +8. Page "Users", "Create user", Username `testuser`, Tab credentials, "Set password", pick a password, Save + +9. Tab "Attributes", "Add an attribute" + * Key: mediamtx_permissions + * Value: {"action":"publish", "paths": "all"} + +10. In MediaMTX, use the following URL: + + ```yml + authJWTJWKS: http://localhost:8080/realms/mediamtx/protocol/openid-connect/certs + ``` ### Encrypt the configuration diff --git a/go.mod b/go.mod index 5127b9055c16..1478f2d491c2 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.21 require ( code.cloudfoundry.org/bytefmt v0.0.0 + github.com/MicahParks/keyfunc/v3 v3.2.5 github.com/abema/go-mp4 v1.2.0 github.com/alecthomas/kong v0.8.1 github.com/bluenviron/gohlslib v1.2.2 @@ -12,6 +13,7 @@ require ( github.com/datarhei/gosrt v0.5.7 github.com/fsnotify/fsnotify v1.7.0 github.com/gin-gonic/gin v1.9.1 + github.com/golang-jwt/jwt/v5 v5.2.0 github.com/google/uuid v1.6.0 github.com/gookit/color v1.5.4 github.com/gorilla/websocket v1.5.1 @@ -32,6 +34,7 @@ require ( ) require ( + github.com/MicahParks/jwkset v0.5.12 // indirect github.com/asticode/go-astikit v0.30.0 // indirect github.com/asticode/go-astits v1.13.0 // indirect github.com/benburkert/openpgp v0.0.0-20160410205803-c2471f86866c // indirect @@ -67,6 +70,7 @@ require ( golang.org/x/arch v0.3.0 // indirect golang.org/x/net v0.21.0 // indirect golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.5.0 // indirect google.golang.org/protobuf v1.30.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 52e1dfad2262..5a8deb883819 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/MicahParks/jwkset v0.5.12 h1:wEwKZXB77yHFIHBtYoawNKIUwqC1X24S8tIhWutJHMA= +github.com/MicahParks/jwkset v0.5.12/go.mod h1:q8ptTGn/Z9c4MwbcfeCDssADeVQb3Pk7PnVxrvi+2QY= +github.com/MicahParks/keyfunc/v3 v3.2.5 h1:eg4s2zd2nfadnAzAsv9xvJCdCfLNy4s/aSiAxRn+aAk= +github.com/MicahParks/keyfunc/v3 v3.2.5/go.mod h1:8hmM7h/hNerfF8uC8cFVnT+afxBgh6nKRTR/0vAm5So= github.com/abema/go-mp4 v1.2.0 h1:gi4X8xg/m179N/J15Fn5ugywN9vtI6PLk6iLldHGLAk= github.com/abema/go-mp4 v1.2.0/go.mod h1:vPl9t5ZK7K0x68jh12/+ECWBCXoWuIDtNgPtU2f04ws= github.com/alecthomas/assert/v2 v2.1.0 h1:tbredtNcQnoSd3QBhQWI7QZ3XHOVkw1Moklp2ojoH/0= @@ -57,6 +61,8 @@ github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QX github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= +github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= @@ -285,6 +291,8 @@ golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= diff --git a/internal/api/api.go b/internal/api/api.go index 4a83f38a15a2..04ca72082653 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "net" "net/http" "os" "reflect" @@ -17,6 +18,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/bluenviron/mediamtx/internal/auth" "github.com/bluenviron/mediamtx/internal/conf" "github.com/bluenviron/mediamtx/internal/defs" "github.com/bluenviron/mediamtx/internal/logger" @@ -159,6 +161,7 @@ type API struct { Address string ReadTimeout conf.StringDuration Conf *conf.Conf + AuthManager *auth.Manager PathManager PathManager RTSPServer RTSPServer RTSPSServer RTSPServer @@ -178,7 +181,7 @@ func (a *API) Initialize() error { router := gin.New() router.SetTrustedProxies(nil) //nolint:errcheck - group := router.Group("/") + group := router.Group("/", a.mwAuth) group.GET("/v3/config/global/get", a.onConfigGlobalGet) group.PATCH("/v3/config/global/patch", a.onConfigGlobalPatch) @@ -287,6 +290,30 @@ func (a *API) writeError(ctx *gin.Context, status int, err error) { }) } +func (a *API) mwAuth(ctx *gin.Context) { + user, pass, hasCredentials := ctx.Request.BasicAuth() + + err := a.AuthManager.Authenticate(&auth.Request{ + User: user, + Pass: pass, + IP: net.ParseIP(ctx.ClientIP()), + Action: conf.AuthActionAPI, + }) + if err != nil { + if !hasCredentials { + ctx.Header("WWW-Authenticate", `Basic realm="mediamtx"`) + ctx.AbortWithStatus(http.StatusUnauthorized) + return + } + + // wait some seconds to mitigate brute force attacks + <-time.After(auth.PauseAfterError) + + ctx.AbortWithStatus(http.StatusUnauthorized) + return + } +} + func (a *API) onConfigGlobalGet(ctx *gin.Context) { a.mutex.RLock() c := a.Conf diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 59a83225260b..ad64b7f7f3a6 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/bluenviron/mediamtx/internal/auth" "github.com/bluenviron/mediamtx/internal/conf" "github.com/bluenviron/mediamtx/internal/logger" "github.com/bluenviron/mediamtx/internal/test" @@ -105,6 +106,22 @@ func TestPaginate(t *testing.T) { require.Equal(t, []int{4, 5}, items) } +var authManager = &auth.Manager{ + Method: conf.AuthMethodInternal, + InternalUsers: []conf.AuthInternalUser{ + { + User: "myuser", + Pass: "mypass", + Permissions: []conf.AuthInternalUserPermission{ + { + Action: conf.AuthActionAPI, + }, + }, + }, + }, + RTSPAuthMethods: nil, +} + func TestConfigGlobalGet(t *testing.T) { cnf := tempConf(t, "api: yes\n") @@ -112,6 +129,7 @@ func TestConfigGlobalGet(t *testing.T) { Address: "localhost:9997", ReadTimeout: conf.StringDuration(10 * time.Second), Conf: cnf, + AuthManager: authManager, Parent: &testParent{}, } err := api.Initialize() @@ -121,7 +139,7 @@ func TestConfigGlobalGet(t *testing.T) { hc := &http.Client{Transport: &http.Transport{}} var out map[string]interface{} - httpRequest(t, hc, http.MethodGet, "http://localhost:9997/v3/config/global/get", nil, &out) + httpRequest(t, hc, http.MethodGet, "http://myuser:mypass@localhost:9997/v3/config/global/get", nil, &out) require.Equal(t, true, out["api"]) } @@ -132,6 +150,7 @@ func TestConfigGlobalPatch(t *testing.T) { Address: "localhost:9997", ReadTimeout: conf.StringDuration(10 * time.Second), Conf: cnf, + AuthManager: authManager, Parent: &testParent{}, } err := api.Initialize() @@ -140,17 +159,18 @@ func TestConfigGlobalPatch(t *testing.T) { hc := &http.Client{Transport: &http.Transport{}} - httpRequest(t, hc, http.MethodPatch, "http://localhost:9997/v3/config/global/patch", map[string]interface{}{ - "rtmp": false, - "readTimeout": "7s", - "protocols": []string{"tcp"}, - "readBufferCount": 4096, // test setting a deprecated parameter - }, nil) + httpRequest(t, hc, http.MethodPatch, "http://myuser:mypass@localhost:9997/v3/config/global/patch", + map[string]interface{}{ + "rtmp": false, + "readTimeout": "7s", + "protocols": []string{"tcp"}, + "readBufferCount": 4096, // test setting a deprecated parameter + }, nil) time.Sleep(500 * time.Millisecond) var out map[string]interface{} - httpRequest(t, hc, http.MethodGet, "http://localhost:9997/v3/config/global/get", nil, &out) + httpRequest(t, hc, http.MethodGet, "http://myuser:mypass@localhost:9997/v3/config/global/get", nil, &out) require.Equal(t, false, out["rtmp"]) require.Equal(t, "7s", out["readTimeout"]) require.Equal(t, []interface{}{"tcp"}, out["protocols"]) @@ -164,6 +184,7 @@ func TestAPIConfigGlobalPatchUnknownField(t *testing.T) { //nolint:dupl Address: "localhost:9997", ReadTimeout: conf.StringDuration(10 * time.Second), Conf: cnf, + AuthManager: authManager, Parent: &testParent{}, } err := api.Initialize() @@ -179,7 +200,8 @@ func TestAPIConfigGlobalPatchUnknownField(t *testing.T) { //nolint:dupl hc := &http.Client{Transport: &http.Transport{}} - req, err := http.NewRequest(http.MethodPatch, "http://localhost:9997/v3/config/global/patch", bytes.NewReader(byts)) + req, err := http.NewRequest(http.MethodPatch, "http://myuser:mypass@localhost:9997/v3/config/global/patch", + bytes.NewReader(byts)) require.NoError(t, err) res, err := hc.Do(req) @@ -197,6 +219,7 @@ func TestAPIConfigPathDefaultsGet(t *testing.T) { Address: "localhost:9997", ReadTimeout: conf.StringDuration(10 * time.Second), Conf: cnf, + AuthManager: authManager, Parent: &testParent{}, } err := api.Initialize() @@ -206,7 +229,7 @@ func TestAPIConfigPathDefaultsGet(t *testing.T) { hc := &http.Client{Transport: &http.Transport{}} var out map[string]interface{} - httpRequest(t, hc, http.MethodGet, "http://localhost:9997/v3/config/pathdefaults/get", nil, &out) + httpRequest(t, hc, http.MethodGet, "http://myuser:mypass@localhost:9997/v3/config/pathdefaults/get", nil, &out) require.Equal(t, "publisher", out["source"]) } @@ -217,6 +240,7 @@ func TestAPIConfigPathDefaultsPatch(t *testing.T) { Address: "localhost:9997", ReadTimeout: conf.StringDuration(10 * time.Second), Conf: cnf, + AuthManager: authManager, Parent: &testParent{}, } err := api.Initialize() @@ -225,15 +249,16 @@ func TestAPIConfigPathDefaultsPatch(t *testing.T) { hc := &http.Client{Transport: &http.Transport{}} - httpRequest(t, hc, http.MethodPatch, "http://localhost:9997/v3/config/pathdefaults/patch", map[string]interface{}{ - "readUser": "myuser", - "readPass": "mypass", - }, nil) + httpRequest(t, hc, http.MethodPatch, "http://myuser:mypass@localhost:9997/v3/config/pathdefaults/patch", + map[string]interface{}{ + "readUser": "myuser", + "readPass": "mypass", + }, nil) time.Sleep(500 * time.Millisecond) var out map[string]interface{} - httpRequest(t, hc, http.MethodGet, "http://localhost:9997/v3/config/pathdefaults/get", nil, &out) + httpRequest(t, hc, http.MethodGet, "http://myuser:mypass@localhost:9997/v3/config/pathdefaults/get", nil, &out) require.Equal(t, "myuser", out["readUser"]) require.Equal(t, "mypass", out["readPass"]) } @@ -252,6 +277,7 @@ func TestAPIConfigPathsList(t *testing.T) { Address: "localhost:9997", ReadTimeout: conf.StringDuration(10 * time.Second), Conf: cnf, + AuthManager: authManager, Parent: &testParent{}, } err := api.Initialize() @@ -269,7 +295,7 @@ func TestAPIConfigPathsList(t *testing.T) { hc := &http.Client{Transport: &http.Transport{}} var out listRes - httpRequest(t, hc, http.MethodGet, "http://localhost:9997/v3/config/paths/list", nil, &out) + httpRequest(t, hc, http.MethodGet, "http://myuser:mypass@localhost:9997/v3/config/paths/list", nil, &out) require.Equal(t, 2, out.ItemCount) require.Equal(t, 1, out.PageCount) require.Equal(t, "path1", out.Items[0]["name"]) @@ -291,6 +317,7 @@ func TestAPIConfigPathsGet(t *testing.T) { Address: "localhost:9997", ReadTimeout: conf.StringDuration(10 * time.Second), Conf: cnf, + AuthManager: authManager, Parent: &testParent{}, } err := api.Initialize() @@ -300,7 +327,7 @@ func TestAPIConfigPathsGet(t *testing.T) { hc := &http.Client{Transport: &http.Transport{}} var out map[string]interface{} - httpRequest(t, hc, http.MethodGet, "http://localhost:9997/v3/config/paths/get/my/path", nil, &out) + httpRequest(t, hc, http.MethodGet, "http://myuser:mypass@localhost:9997/v3/config/paths/get/my/path", nil, &out) require.Equal(t, "my/path", out["name"]) require.Equal(t, "myuser", out["readUser"]) } @@ -312,6 +339,7 @@ func TestAPIConfigPathsAdd(t *testing.T) { Address: "localhost:9997", ReadTimeout: conf.StringDuration(10 * time.Second), Conf: cnf, + AuthManager: authManager, Parent: &testParent{}, } err := api.Initialize() @@ -320,15 +348,16 @@ func TestAPIConfigPathsAdd(t *testing.T) { hc := &http.Client{Transport: &http.Transport{}} - httpRequest(t, hc, http.MethodPost, "http://localhost:9997/v3/config/paths/add/my/path", map[string]interface{}{ - "source": "rtsp://127.0.0.1:9999/mypath", - "sourceOnDemand": true, - "disablePublisherOverride": true, // test setting a deprecated parameter - "rpiCameraVFlip": true, - }, nil) + httpRequest(t, hc, http.MethodPost, "http://myuser:mypass@localhost:9997/v3/config/paths/add/my/path", + map[string]interface{}{ + "source": "rtsp://127.0.0.1:9999/mypath", + "sourceOnDemand": true, + "disablePublisherOverride": true, // test setting a deprecated parameter + "rpiCameraVFlip": true, + }, nil) var out map[string]interface{} - httpRequest(t, hc, http.MethodGet, "http://localhost:9997/v3/config/paths/get/my/path", nil, &out) + httpRequest(t, hc, http.MethodGet, "http://myuser:mypass@localhost:9997/v3/config/paths/get/my/path", nil, &out) require.Equal(t, "rtsp://127.0.0.1:9999/mypath", out["source"]) require.Equal(t, true, out["sourceOnDemand"]) require.Equal(t, true, out["disablePublisherOverride"]) @@ -342,6 +371,7 @@ func TestAPIConfigPathsAddUnknownField(t *testing.T) { //nolint:dupl Address: "localhost:9997", ReadTimeout: conf.StringDuration(10 * time.Second), Conf: cnf, + AuthManager: authManager, Parent: &testParent{}, } err := api.Initialize() @@ -358,7 +388,7 @@ func TestAPIConfigPathsAddUnknownField(t *testing.T) { //nolint:dupl hc := &http.Client{Transport: &http.Transport{}} req, err := http.NewRequest(http.MethodPost, - "http://localhost:9997/v3/config/paths/add/my/path", bytes.NewReader(byts)) + "http://myuser:mypass@localhost:9997/v3/config/paths/add/my/path", bytes.NewReader(byts)) require.NoError(t, err) res, err := hc.Do(req) @@ -376,6 +406,7 @@ func TestAPIConfigPathsPatch(t *testing.T) { //nolint:dupl Address: "localhost:9997", ReadTimeout: conf.StringDuration(10 * time.Second), Conf: cnf, + AuthManager: authManager, Parent: &testParent{}, } err := api.Initialize() @@ -384,20 +415,22 @@ func TestAPIConfigPathsPatch(t *testing.T) { //nolint:dupl hc := &http.Client{Transport: &http.Transport{}} - httpRequest(t, hc, http.MethodPost, "http://localhost:9997/v3/config/paths/add/my/path", map[string]interface{}{ - "source": "rtsp://127.0.0.1:9999/mypath", - "sourceOnDemand": true, - "disablePublisherOverride": true, // test setting a deprecated parameter - "rpiCameraVFlip": true, - }, nil) + httpRequest(t, hc, http.MethodPost, "http://myuser:mypass@localhost:9997/v3/config/paths/add/my/path", + map[string]interface{}{ + "source": "rtsp://127.0.0.1:9999/mypath", + "sourceOnDemand": true, + "disablePublisherOverride": true, // test setting a deprecated parameter + "rpiCameraVFlip": true, + }, nil) - httpRequest(t, hc, http.MethodPatch, "http://localhost:9997/v3/config/paths/patch/my/path", map[string]interface{}{ - "source": "rtsp://127.0.0.1:9998/mypath", - "sourceOnDemand": true, - }, nil) + httpRequest(t, hc, http.MethodPatch, "http://myuser:mypass@localhost:9997/v3/config/paths/patch/my/path", + map[string]interface{}{ + "source": "rtsp://127.0.0.1:9998/mypath", + "sourceOnDemand": true, + }, nil) var out map[string]interface{} - httpRequest(t, hc, http.MethodGet, "http://localhost:9997/v3/config/paths/get/my/path", nil, &out) + httpRequest(t, hc, http.MethodGet, "http://myuser:mypass@localhost:9997/v3/config/paths/get/my/path", nil, &out) require.Equal(t, "rtsp://127.0.0.1:9998/mypath", out["source"]) require.Equal(t, true, out["sourceOnDemand"]) require.Equal(t, true, out["disablePublisherOverride"]) @@ -411,6 +444,7 @@ func TestAPIConfigPathsReplace(t *testing.T) { //nolint:dupl Address: "localhost:9997", ReadTimeout: conf.StringDuration(10 * time.Second), Conf: cnf, + AuthManager: authManager, Parent: &testParent{}, } err := api.Initialize() @@ -419,20 +453,22 @@ func TestAPIConfigPathsReplace(t *testing.T) { //nolint:dupl hc := &http.Client{Transport: &http.Transport{}} - httpRequest(t, hc, http.MethodPost, "http://localhost:9997/v3/config/paths/add/my/path", map[string]interface{}{ - "source": "rtsp://127.0.0.1:9999/mypath", - "sourceOnDemand": true, - "disablePublisherOverride": true, // test setting a deprecated parameter - "rpiCameraVFlip": true, - }, nil) + httpRequest(t, hc, http.MethodPost, "http://myuser:mypass@localhost:9997/v3/config/paths/add/my/path", + map[string]interface{}{ + "source": "rtsp://127.0.0.1:9999/mypath", + "sourceOnDemand": true, + "disablePublisherOverride": true, // test setting a deprecated parameter + "rpiCameraVFlip": true, + }, nil) - httpRequest(t, hc, http.MethodPost, "http://localhost:9997/v3/config/paths/replace/my/path", map[string]interface{}{ - "source": "rtsp://127.0.0.1:9998/mypath", - "sourceOnDemand": true, - }, nil) + httpRequest(t, hc, http.MethodPost, "http://myuser:mypass@localhost:9997/v3/config/paths/replace/my/path", + map[string]interface{}{ + "source": "rtsp://127.0.0.1:9998/mypath", + "sourceOnDemand": true, + }, nil) var out map[string]interface{} - httpRequest(t, hc, http.MethodGet, "http://localhost:9997/v3/config/paths/get/my/path", nil, &out) + httpRequest(t, hc, http.MethodGet, "http://myuser:mypass@localhost:9997/v3/config/paths/get/my/path", nil, &out) require.Equal(t, "rtsp://127.0.0.1:9998/mypath", out["source"]) require.Equal(t, true, out["sourceOnDemand"]) require.Equal(t, nil, out["disablePublisherOverride"]) @@ -446,6 +482,7 @@ func TestAPIConfigPathsDelete(t *testing.T) { Address: "localhost:9997", ReadTimeout: conf.StringDuration(10 * time.Second), Conf: cnf, + AuthManager: authManager, Parent: &testParent{}, } err := api.Initialize() @@ -454,14 +491,15 @@ func TestAPIConfigPathsDelete(t *testing.T) { hc := &http.Client{Transport: &http.Transport{}} - httpRequest(t, hc, http.MethodPost, "http://localhost:9997/v3/config/paths/add/my/path", map[string]interface{}{ - "source": "rtsp://127.0.0.1:9999/mypath", - "sourceOnDemand": true, - }, nil) + httpRequest(t, hc, http.MethodPost, "http://myuser:mypass@localhost:9997/v3/config/paths/add/my/path", + map[string]interface{}{ + "source": "rtsp://127.0.0.1:9999/mypath", + "sourceOnDemand": true, + }, nil) - httpRequest(t, hc, http.MethodDelete, "http://localhost:9997/v3/config/paths/delete/my/path", nil, nil) + httpRequest(t, hc, http.MethodDelete, "http://myuser:mypass@localhost:9997/v3/config/paths/delete/my/path", nil, nil) - req, err := http.NewRequest(http.MethodGet, "http://localhost:9997/v3/config/paths/get/my/path", nil) + req, err := http.NewRequest(http.MethodGet, "http://myuser:mypass@localhost:9997/v3/config/paths/get/my/path", nil) require.NoError(t, err) res, err := hc.Do(req) @@ -486,6 +524,7 @@ func TestRecordingsList(t *testing.T) { Address: "localhost:9997", ReadTimeout: conf.StringDuration(10 * time.Second), Conf: cnf, + AuthManager: authManager, Parent: &testParent{}, } err = api.Initialize() @@ -510,7 +549,7 @@ func TestRecordingsList(t *testing.T) { hc := &http.Client{Transport: &http.Transport{}} var out interface{} - httpRequest(t, hc, http.MethodGet, "http://localhost:9997/v3/recordings/list", nil, &out) + httpRequest(t, hc, http.MethodGet, "http://myuser:mypass@localhost:9997/v3/recordings/list", nil, &out) require.Equal(t, map[string]interface{}{ "itemCount": float64(2), "pageCount": float64(1), @@ -552,6 +591,7 @@ func TestRecordingsGet(t *testing.T) { Address: "localhost:9997", ReadTimeout: conf.StringDuration(10 * time.Second), Conf: cnf, + AuthManager: authManager, Parent: &testParent{}, } err = api.Initialize() @@ -570,7 +610,7 @@ func TestRecordingsGet(t *testing.T) { hc := &http.Client{Transport: &http.Transport{}} var out interface{} - httpRequest(t, hc, http.MethodGet, "http://localhost:9997/v3/recordings/get/mypath1", nil, &out) + httpRequest(t, hc, http.MethodGet, "http://myuser:mypass@localhost:9997/v3/recordings/get/mypath1", nil, &out) require.Equal(t, map[string]interface{}{ "name": "mypath1", "segments": []interface{}{ @@ -598,6 +638,7 @@ func TestRecordingsDeleteSegment(t *testing.T) { Address: "localhost:9997", ReadTimeout: conf.StringDuration(10 * time.Second), Conf: cnf, + AuthManager: authManager, Parent: &testParent{}, } err = api.Initialize() @@ -612,16 +653,13 @@ func TestRecordingsDeleteSegment(t *testing.T) { hc := &http.Client{Transport: &http.Transport{}} + u, err := url.Parse("http://myuser:mypass@localhost:9997/v3/recordings/deletesegment") + require.NoError(t, err) + v := url.Values{} v.Set("path", "mypath1") v.Set("start", time.Date(2008, 11, 0o7, 11, 22, 0, 900000000, time.Local).Format(time.RFC3339Nano)) - - u := &url.URL{ - Scheme: "http", - Host: "localhost:9997", - Path: "/v3/recordings/deletesegment", - RawQuery: v.Encode(), - } + u.RawQuery = v.Encode() req, err := http.NewRequest(http.MethodDelete, u.String(), nil) require.NoError(t, err) diff --git a/internal/auth/manager.go b/internal/auth/manager.go new file mode 100644 index 000000000000..c58dcebbf7fa --- /dev/null +++ b/internal/auth/manager.go @@ -0,0 +1,327 @@ +// Package auth contains the authentication system. +package auth + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "regexp" + "strings" + "sync" + "time" + + "github.com/MicahParks/keyfunc/v3" + "github.com/bluenviron/gortsplib/v4/pkg/auth" + "github.com/bluenviron/gortsplib/v4/pkg/base" + "github.com/bluenviron/gortsplib/v4/pkg/headers" + "github.com/bluenviron/mediamtx/internal/conf" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +const ( + // PauseAfterError is the pause to apply after an authentication failure. + PauseAfterError = 2 * time.Second + + rtspAuthRealm = "IPCAM" + jwtRefreshPeriod = 60 * 60 * time.Second +) + +// Protocol is a protocol. +type Protocol string + +// protocols. +const ( + ProtocolRTSP Protocol = "rtsp" + ProtocolRTMP Protocol = "rtmp" + ProtocolHLS Protocol = "hls" + ProtocolWebRTC Protocol = "webrtc" + ProtocolSRT Protocol = "srt" +) + +// Request is an authentication request. +type Request struct { + User string + Pass string + IP net.IP + Action conf.AuthAction + + // only for ActionPublish, ActionRead, ActionPlayback + Path string + Protocol Protocol + ID *uuid.UUID + Query string + RTSPRequest *base.Request + RTSPBaseURL *base.URL + RTSPNonce string +} + +// Error is a authentication error. +type Error struct { + Message string +} + +// Error implements the error interface. +func (e Error) Error() string { + return "authentication failed: " + e.Message +} + +func matchesPermission(perms []conf.AuthInternalUserPermission, req *Request) bool { + for _, perm := range perms { + if perm.Action == req.Action { + if perm.Action == conf.AuthActionPublish || + perm.Action == conf.AuthActionRead || + perm.Action == conf.AuthActionPlayback { + switch { + case perm.Path == "": + return true + + case strings.HasPrefix(perm.Path, "~"): + regexp, err := regexp.Compile(perm.Path[1:]) + if err == nil && regexp.MatchString(req.Path) { + return true + } + + case perm.Path == req.Path: + return true + } + } else { + return true + } + } + } + + return false +} + +type customClaims struct { + jwt.RegisteredClaims + MediaMTXPermissions []conf.AuthInternalUserPermission `json:"mediamtx_permissions"` +} + +// Manager is the authentication manager. +type Manager struct { + Method conf.AuthMethod + InternalUsers []conf.AuthInternalUser + HTTPAddress string + HTTPExclude []conf.AuthInternalUserPermission + JWTJWKS string + ReadTimeout time.Duration + RTSPAuthMethods []headers.AuthMethod + + mutex sync.RWMutex + jwtHTTPClient *http.Client + jwtLastRefresh time.Time + jwtKeyFunc keyfunc.Keyfunc +} + +// ReloadInternalUsers reloads InternalUsers. +func (m *Manager) ReloadInternalUsers(u []conf.AuthInternalUser) { + m.mutex.Lock() + defer m.mutex.Unlock() + m.InternalUsers = u +} + +// Authenticate authenticates a request. +func (m *Manager) Authenticate(req *Request) error { + err := m.authenticateInner(req) + if err != nil { + return Error{Message: err.Error()} + } + return nil +} + +func (m *Manager) authenticateInner(req *Request) error { + // if this is a RTSP request, fill username and password + var rtspAuthHeader headers.Authorization + if req.RTSPRequest != nil { + err := rtspAuthHeader.Unmarshal(req.RTSPRequest.Header["Authorization"]) + if err == nil { + switch rtspAuthHeader.Method { + case headers.AuthBasic: + req.User = rtspAuthHeader.BasicUser + req.Pass = rtspAuthHeader.BasicPass + + case headers.AuthDigestMD5: + req.User = rtspAuthHeader.Username + + default: + return fmt.Errorf("unsupported RTSP authentication method") + } + } + } + + switch m.Method { + case conf.AuthMethodInternal: + return m.authenticateInternal(req, &rtspAuthHeader) + + case conf.AuthMethodHTTP: + return m.authenticateHTTP(req) + + default: + return m.authenticateJWT(req) + } +} + +func (m *Manager) authenticateInternal(req *Request, rtspAuthHeader *headers.Authorization) error { + m.mutex.RLock() + defer m.mutex.RUnlock() + + for _, u := range m.InternalUsers { + if err := m.authenticateWithUser(req, rtspAuthHeader, &u); err == nil { + return nil + } + } + + return fmt.Errorf("authentication failed") +} + +func (m *Manager) authenticateWithUser( + req *Request, + rtspAuthHeader *headers.Authorization, + u *conf.AuthInternalUser, +) error { + if u.User != "any" && !u.User.Check(req.User) { + return fmt.Errorf("wrong user") + } + + if len(u.IPs) != 0 && !u.IPs.Contains(req.IP) { + return fmt.Errorf("IP not allowed") + } + + if !matchesPermission(u.Permissions, req) { + return fmt.Errorf("user doesn't have permission to perform action") + } + + if u.User != "any" { + if req.RTSPRequest != nil && rtspAuthHeader.Method == headers.AuthDigestMD5 { + err := auth.Validate( + req.RTSPRequest, + string(u.User), + string(u.Pass), + req.RTSPBaseURL, + m.RTSPAuthMethods, + rtspAuthRealm, + req.RTSPNonce) + if err != nil { + return err + } + } else if !u.Pass.Check(req.Pass) { + return fmt.Errorf("invalid credentials") + } + } + + return nil +} + +func (m *Manager) authenticateHTTP(req *Request) error { + if matchesPermission(m.HTTPExclude, req) { + return nil + } + + enc, _ := json.Marshal(struct { + IP string `json:"ip"` + User string `json:"user"` + Password string `json:"password"` + Action string `json:"action"` + Path string `json:"path"` + Protocol string `json:"protocol"` + ID *uuid.UUID `json:"id"` + Query string `json:"query"` + }{ + IP: req.IP.String(), + User: req.User, + Password: req.Pass, + Action: string(req.Action), + Path: req.Path, + Protocol: string(req.Protocol), + ID: req.ID, + Query: req.Query, + }) + + res, err := http.Post(m.HTTPAddress, "application/json", bytes.NewReader(enc)) + if err != nil { + return fmt.Errorf("HTTP request failed: %w", err) + } + defer res.Body.Close() + + if res.StatusCode < 200 || res.StatusCode > 299 { + if resBody, err := io.ReadAll(res.Body); err == nil && len(resBody) != 0 { + return fmt.Errorf("server replied with code %d: %s", res.StatusCode, string(resBody)) + } + + return fmt.Errorf("server replied with code %d", res.StatusCode) + } + + return nil +} + +func (m *Manager) authenticateJWT(req *Request) error { + keyfunc, err := m.pullJWTJWKS() + if err != nil { + return err + } + + v, err := url.ParseQuery(req.Query) + if err != nil { + return err + } + + if len(v["jwt"]) != 1 { + return fmt.Errorf("JWT not provided") + } + + var customClaims customClaims + _, err = jwt.ParseWithClaims(v["jwt"][0], &customClaims, keyfunc) + if err != nil { + return err + } + + if !matchesPermission(customClaims.MediaMTXPermissions, req) { + return fmt.Errorf("user doesn't have permission to perform action") + } + + return nil +} + +func (m *Manager) pullJWTJWKS() (jwt.Keyfunc, error) { + now := time.Now() + + m.mutex.Lock() + defer m.mutex.Unlock() + + if now.Sub(m.jwtLastRefresh) >= jwtRefreshPeriod { + if m.jwtHTTPClient == nil { + m.jwtHTTPClient = &http.Client{ + Timeout: (m.ReadTimeout), + Transport: &http.Transport{}, + } + } + + res, err := m.jwtHTTPClient.Get(m.JWTJWKS) + if err != nil { + return nil, err + } + defer res.Body.Close() + + var raw json.RawMessage + err = json.NewDecoder(res.Body).Decode(&raw) + if err != nil { + return nil, err + } + + tmp, err := keyfunc.NewJWKSetJSON(raw) + if err != nil { + return nil, err + } + + m.jwtKeyFunc = tmp + m.jwtLastRefresh = now + } + + return m.jwtKeyFunc.Keyfunc, nil +} diff --git a/internal/auth/manager_test.go b/internal/auth/manager_test.go new file mode 100644 index 000000000000..c97a1a589fc1 --- /dev/null +++ b/internal/auth/manager_test.go @@ -0,0 +1,309 @@ +package auth + +import ( + "context" + "encoding/json" + "net" + "net/http" + "testing" + + "github.com/bluenviron/gortsplib/v4/pkg/auth" + "github.com/bluenviron/gortsplib/v4/pkg/base" + "github.com/bluenviron/gortsplib/v4/pkg/headers" + "github.com/bluenviron/mediamtx/internal/conf" + "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 +} + +type testHTTPAuthenticator struct { + *http.Server +} + +func (ts *testHTTPAuthenticator) initialize(t *testing.T, protocol string, action string) { + firstReceived := false + + ts.Server = &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) + + var user string + if action == "publish" { + user = "testpublisher" + } else { + user = "testreader" + } + + if in.IP != "127.0.0.1" || + in.User != user || + in.Password != "testpass" || + in.Path != "teststream" || + in.Protocol != protocol || + (firstReceived && in.ID == "") || + in.Action != action || + (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 ts.Server.Serve(ln) +} + +func (ts *testHTTPAuthenticator) close() { + ts.Server.Shutdown(context.Background()) +} + +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", + }, + }, + }, + }, + HTTPAddress: "", + RTSPAuthMethods: nil, + } + + 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") + } + + switch outcome { + case "ok": + err := m.Authenticate(&Request{ + User: "testuser", + Pass: "testpass", + IP: net.ParseIP("127.1.1.1"), + Action: conf.AuthActionPublish, + Path: "mypath", + }) + require.NoError(t, err) + + case "wrong user": + err := m.Authenticate(&Request{ + User: "wrong", + Pass: "testpass", + IP: net.ParseIP("127.1.1.1"), + Action: conf.AuthActionPublish, + Path: "mypath", + }) + require.Error(t, err) + + case "wrong pass": + err := m.Authenticate(&Request{ + User: "testuser", + Pass: "wrong", + IP: net.ParseIP("127.1.1.1"), + Action: conf.AuthActionPublish, + Path: "mypath", + }) + require.Error(t, err) + + case "wrong ip": + err := m.Authenticate(&Request{ + User: "testuser", + Pass: "testpass", + IP: net.ParseIP("127.1.1.2"), + Action: conf.AuthActionPublish, + Path: "mypath", + }) + require.Error(t, err) + + case "wrong action": + err := m.Authenticate(&Request{ + User: "testuser", + Pass: "testpass", + IP: net.ParseIP("127.1.1.1"), + Action: conf.AuthActionRead, + Path: "mypath", + }) + require.Error(t, err) + + case "wrong path": + err := m.Authenticate(&Request{ + User: "testuser", + Pass: "testpass", + IP: net.ParseIP("127.1.1.1"), + Action: conf.AuthActionPublish, + Path: "wrong", + }) + require.Error(t, err) + } + }) + } + } +} + +func TestAuthInternalRTSPDigest(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", + }, + }, + }, + }, + HTTPAddress: "", + RTSPAuthMethods: []headers.AuthMethod{headers.AuthDigestMD5}, + } + + u, err := base.ParseURL("rtsp://127.0.0.1:8554/mypath") + require.NoError(t, err) + + s, err := auth.NewSender( + auth.GenerateWWWAuthenticate([]headers.AuthMethod{headers.AuthDigestMD5}, "IPCAM", "mynonce"), + "myuser", + "mypass", + ) + require.NoError(t, err) + + req := &base.Request{ + Method: "ANNOUNCE", + URL: u, + } + + s.AddAuthorization(req) + + err = m.Authenticate(&Request{ + IP: net.ParseIP("127.1.1.1"), + Action: conf.AuthActionPublish, + Path: "mypath", + RTSPRequest: req, + RTSPNonce: "mynonce", + }) + require.NoError(t, err) +} + +func TestAuthHTTP(t *testing.T) { + for _, outcome := range []string{"ok", "fail"} { + t.Run(outcome, func(t *testing.T) { + m := Manager{ + Method: conf.AuthMethodHTTP, + HTTPAddress: "http://127.0.0.1:9120/auth", + RTSPAuthMethods: nil, + } + + au := &testHTTPAuthenticator{} + au.initialize(t, "rtsp", "publish") + defer au.close() + + if outcome == "ok" { + err := m.Authenticate(&Request{ + User: "testpublisher", + Pass: "testpass", + IP: net.ParseIP("127.0.0.1"), + Action: conf.AuthActionPublish, + Path: "teststream", + Protocol: ProtocolRTSP, + Query: "param=value", + }) + require.NoError(t, err) + } else { + err := m.Authenticate(&Request{ + User: "invalid", + Pass: "testpass", + IP: net.ParseIP("127.0.0.1"), + Action: conf.AuthActionPublish, + Path: "teststream", + Protocol: ProtocolRTSP, + Query: "param=value", + }) + require.Error(t, err) + } + }) + } +} + +func TestAuthHTTPExclude(t *testing.T) { + m := Manager{ + Method: conf.AuthMethodHTTP, + HTTPAddress: "http://not-to-be-used:9120/auth", + HTTPExclude: []conf.AuthInternalUserPermission{{ + Action: conf.AuthActionPublish, + }}, + RTSPAuthMethods: nil, + } + + err := m.Authenticate(&Request{ + User: "", + Pass: "", + IP: net.ParseIP("127.0.0.1"), + Action: conf.AuthActionPublish, + Path: "teststream", + Protocol: ProtocolRTSP, + Query: "param=value", + }) + require.NoError(t, err) +} diff --git a/internal/conf/auth_action.go b/internal/conf/auth_action.go new file mode 100644 index 000000000000..2d6b361585a8 --- /dev/null +++ b/internal/conf/auth_action.go @@ -0,0 +1,52 @@ +package conf + +import ( + "encoding/json" + "fmt" +) + +// AuthAction is an authentication action. +type AuthAction string + +// auth actions +const ( + AuthActionPublish AuthAction = "publish" + AuthActionRead AuthAction = "read" + AuthActionPlayback AuthAction = "playback" + AuthActionAPI AuthAction = "api" + AuthActionMetrics AuthAction = "metrics" + AuthActionPprof AuthAction = "pprof" +) + +// MarshalJSON implements json.Marshaler. +func (d AuthAction) MarshalJSON() ([]byte, error) { + return json.Marshal(string(d)) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (d *AuthAction) UnmarshalJSON(b []byte) error { + var in string + if err := json.Unmarshal(b, &in); err != nil { + return err + } + + switch in { + case string(AuthActionPublish), + string(AuthActionRead), + string(AuthActionPlayback), + string(AuthActionAPI), + string(AuthActionMetrics), + string(AuthActionPprof): + *d = AuthAction(in) + + default: + return fmt.Errorf("invalid auth action: '%s'", in) + } + + return nil +} + +// UnmarshalEnv implements env.Unmarshaler. +func (d *AuthAction) UnmarshalEnv(_ string, v string) error { + return d.UnmarshalJSON([]byte(`"` + v + `"`)) +} diff --git a/internal/conf/auth_internal_users.go b/internal/conf/auth_internal_users.go new file mode 100644 index 000000000000..76cdc959ecdc --- /dev/null +++ b/internal/conf/auth_internal_users.go @@ -0,0 +1,15 @@ +package conf + +// AuthInternalUserPermission is a permission of a user. +type AuthInternalUserPermission struct { + Action AuthAction `json:"action"` + Path string `json:"path"` +} + +// AuthInternalUser is an user. +type AuthInternalUser struct { + User Credential `json:"user"` + Pass Credential `json:"pass"` + IPs IPNetworks `json:"ips"` + Permissions []AuthInternalUserPermission `json:"permissions"` +} diff --git a/internal/conf/auth_method.go b/internal/conf/auth_method.go new file mode 100644 index 000000000000..eb707a3c851a --- /dev/null +++ b/internal/conf/auth_method.go @@ -0,0 +1,63 @@ +package conf + +import ( + "encoding/json" + "fmt" +) + +// AuthMethod is an authentication method. +type AuthMethod int + +// authentication methods. +const ( + AuthMethodInternal AuthMethod = iota + AuthMethodHTTP + AuthMethodJWT +) + +// MarshalJSON implements json.Marshaler. +func (d AuthMethod) MarshalJSON() ([]byte, error) { + var out string + + switch d { + case AuthMethodInternal: + out = "internal" + + case AuthMethodHTTP: + out = "http" + + default: + out = "jwt" + } + + return json.Marshal(out) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (d *AuthMethod) UnmarshalJSON(b []byte) error { + var in string + if err := json.Unmarshal(b, &in); err != nil { + return err + } + + switch in { + case "internal": + *d = AuthMethodInternal + + case "http": + *d = AuthMethodHTTP + + case "jwt": + *d = AuthMethodJWT + + default: + return fmt.Errorf("invalid authMethod: '%s'", in) + } + + return nil +} + +// UnmarshalEnv implements env.Unmarshaler. +func (d *AuthMethod) UnmarshalEnv(_ string, v string) error { + return d.UnmarshalJSON([]byte(`"` + v + `"`)) +} diff --git a/internal/conf/conf.go b/internal/conf/conf.go index 18663316bf4a..b10f4e16d890 100644 --- a/internal/conf/conf.go +++ b/internal/conf/conf.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "net" "os" "reflect" "sort" @@ -82,25 +83,58 @@ func copyStructFields(dest interface{}, source interface{}) { } } +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 anyPathHasDeprecatedCredentials(paths map[string]*OptionalPath) bool { + for _, pa := range paths { + if pa != nil { + rva := reflect.ValueOf(pa.Values).Elem() + if !rva.FieldByName("PublishUser").IsNil() || !rva.FieldByName("PublishPass").IsNil() || + !rva.FieldByName("PublishIPs").IsNil() || + !rva.FieldByName("ReadUser").IsNil() || !rva.FieldByName("ReadPass").IsNil() || + !rva.FieldByName("ReadIPs").IsNil() { + return true + } + } + } + return false +} + // Conf is a configuration. type Conf struct { // General - LogLevel LogLevel `json:"logLevel"` - LogDestinations LogDestinations `json:"logDestinations"` - LogFile string `json:"logFile"` - ReadTimeout StringDuration `json:"readTimeout"` - WriteTimeout StringDuration `json:"writeTimeout"` - ReadBufferCount *int `json:"readBufferCount,omitempty"` // deprecated - WriteQueueSize int `json:"writeQueueSize"` - UDPMaxPayloadSize int `json:"udpMaxPayloadSize"` - ExternalAuthenticationURL string `json:"externalAuthenticationURL"` - Metrics bool `json:"metrics"` - MetricsAddress string `json:"metricsAddress"` - PPROF bool `json:"pprof"` - PPROFAddress string `json:"pprofAddress"` - RunOnConnect string `json:"runOnConnect"` - RunOnConnectRestart bool `json:"runOnConnectRestart"` - RunOnDisconnect string `json:"runOnDisconnect"` + LogLevel LogLevel `json:"logLevel"` + LogDestinations LogDestinations `json:"logDestinations"` + LogFile string `json:"logFile"` + ReadTimeout StringDuration `json:"readTimeout"` + WriteTimeout StringDuration `json:"writeTimeout"` + ReadBufferCount *int `json:"readBufferCount,omitempty"` // deprecated + WriteQueueSize int `json:"writeQueueSize"` + UDPMaxPayloadSize int `json:"udpMaxPayloadSize"` + Metrics bool `json:"metrics"` + MetricsAddress string `json:"metricsAddress"` + PPROF bool `json:"pprof"` + PPROFAddress string `json:"pprofAddress"` + RunOnConnect string `json:"runOnConnect"` + RunOnConnectRestart bool `json:"runOnConnectRestart"` + RunOnDisconnect string `json:"runOnDisconnect"` + + // Authentication + AuthMethod AuthMethod `json:"authMethod"` + AuthInternalUsers []AuthInternalUser `json:"authInternalUsers"` + AuthHTTPAddress string `json:"authHTTPAddress"` + ExternalAuthenticationURL *string `json:"externalAuthenticationURL,omitempty"` // deprecated + AuthHTTPExclude []AuthInternalUserPermission `json:"authHTTPExclude"` + AuthJWTJWKS string `json:"authJWTJWKS"` // API API bool `json:"api"` @@ -202,11 +236,57 @@ func (conf *Conf) setDefaults() { conf.WriteTimeout = 10 * StringDuration(time.Second) conf.WriteQueueSize = 512 conf.UDPMaxPayloadSize = 1472 - conf.MetricsAddress = "127.0.0.1:9998" - conf.PPROFAddress = "127.0.0.1:9999" + conf.MetricsAddress = ":9998" + conf.PPROFAddress = ":9999" + + // Authentication + conf.AuthInternalUsers = []AuthInternalUser{ + { + User: "any", + Pass: "", + Permissions: []AuthInternalUserPermission{ + { + Action: AuthActionPublish, + }, + { + Action: AuthActionRead, + }, + { + Action: AuthActionPlayback, + }, + }, + }, + { + User: "any", + Pass: "", + IPs: IPNetworks{mustParseCIDR("127.0.0.1/32"), mustParseCIDR("::1/128")}, + Permissions: []AuthInternalUserPermission{ + { + Action: AuthActionAPI, + }, + { + Action: AuthActionMetrics, + }, + { + Action: AuthActionPprof, + }, + }, + }, + } + conf.AuthHTTPExclude = []AuthInternalUserPermission{ + { + Action: AuthActionAPI, + }, + { + Action: AuthActionMetrics, + }, + { + Action: AuthActionPprof, + }, + } // API - conf.APIAddress = "127.0.0.1:9997" + conf.APIAddress = ":9997" // Playback server conf.PlaybackAddress = ":9996" @@ -362,10 +442,67 @@ func (conf *Conf) Validate() error { if conf.UDPMaxPayloadSize > 1472 { return fmt.Errorf("'udpMaxPayloadSize' must be less than 1472") } - if conf.ExternalAuthenticationURL != "" { - if !strings.HasPrefix(conf.ExternalAuthenticationURL, "http://") && - !strings.HasPrefix(conf.ExternalAuthenticationURL, "https://") { - return fmt.Errorf("'externalAuthenticationURL' must be a HTTP URL") + + // Authentication + + if conf.ExternalAuthenticationURL != nil { + conf.AuthMethod = AuthMethodHTTP + conf.AuthHTTPAddress = *conf.ExternalAuthenticationURL + } + if conf.AuthHTTPAddress != "" && + !strings.HasPrefix(conf.AuthHTTPAddress, "http://") && + !strings.HasPrefix(conf.AuthHTTPAddress, "https://") { + return fmt.Errorf("'externalAuthenticationURL' must be a HTTP URL") + } + if conf.AuthJWTJWKS != "" && + !strings.HasPrefix(conf.AuthJWTJWKS, "http://") && + !strings.HasPrefix(conf.AuthJWTJWKS, "https://") { + return fmt.Errorf("'authJWTJWKS' must be a HTTP URL") + } + deprecatedCredentialsMode := false + if conf.PathDefaults.PublishUser != nil || conf.PathDefaults.PublishPass != nil || + conf.PathDefaults.PublishIPs != nil || + conf.PathDefaults.ReadUser != nil || conf.PathDefaults.ReadPass != nil || + conf.PathDefaults.ReadIPs != nil || + anyPathHasDeprecatedCredentials(conf.OptionalPaths) { + conf.AuthInternalUsers = []AuthInternalUser{ + { + User: "any", + Pass: "", + Permissions: []AuthInternalUserPermission{ + { + Action: AuthActionPlayback, + }, + }, + }, + { + User: "any", + Pass: "", + IPs: IPNetworks{mustParseCIDR("127.0.0.1/32"), mustParseCIDR("::1/128")}, + Permissions: []AuthInternalUserPermission{ + { + Action: AuthActionAPI, + }, + { + Action: AuthActionMetrics, + }, + { + Action: AuthActionPprof, + }, + }, + }, + } + deprecatedCredentialsMode = true + } + switch conf.AuthMethod { + case AuthMethodHTTP: + if conf.AuthHTTPAddress == "" { + return fmt.Errorf("'authHTTPAddress' is empty") + } + + case AuthMethodJWT: + if conf.AuthJWTJWKS == "" { + return fmt.Errorf("'authJWTJWKS' is empty") } } @@ -385,8 +522,15 @@ func (conf *Conf) Validate() error { if conf.AuthMethods != nil { conf.RTSPAuthMethods = *conf.AuthMethods } - if conf.ExternalAuthenticationURL != "" && contains(conf.RTSPAuthMethods, headers.AuthDigestMD5) { - return fmt.Errorf("'externalAuthenticationURL' can't be used when 'digest' is in authMethods") + if contains(conf.RTSPAuthMethods, headers.AuthDigestMD5) { + if conf.AuthMethod != AuthMethodInternal { + return fmt.Errorf("when RTSP digest is enabled, the only supported auth method is 'internal'") + } + for _, user := range conf.AuthInternalUsers { + if user.User.IsHashed() || user.Pass.IsHashed() { + return fmt.Errorf("when RTSP digest is enabled, hashed credentials cannot be used") + } + } } // RTMP @@ -493,7 +637,7 @@ func (conf *Conf) Validate() error { pconf := newPath(&conf.PathDefaults, optional) conf.Paths[name] = pconf - err := pconf.validate(conf, name) + err := pconf.validate(conf, name, deprecatedCredentialsMode) if err != nil { return err } diff --git a/internal/conf/conf_test.go b/internal/conf/conf_test.go index 40f48f3f11a7..1018b2922dab 100644 --- a/internal/conf/conf_test.go +++ b/internal/conf/conf_test.go @@ -214,17 +214,6 @@ func TestConfErrors(t *testing.T) { "udpMaxPayloadSize: 5000\n", "'udpMaxPayloadSize' must be less than 1472", }, - { - "invalid externalAuthenticationURL 1", - "externalAuthenticationURL: testing\n", - "'externalAuthenticationURL' must be a HTTP URL", - }, - { - "invalid externalAuthenticationURL 2", - "externalAuthenticationURL: http://myurl\n" + - "authMethods: [digest]\n", - "'externalAuthenticationURL' can't be used when 'digest' is in authMethods", - }, { "invalid strict encryption 1", "encryption: strict\n" + diff --git a/internal/conf/ip_networks.go b/internal/conf/ip_networks.go index 8f6bd63e27a5..d9f50d9e47c2 100644 --- a/internal/conf/ip_networks.go +++ b/internal/conf/ip_networks.go @@ -9,7 +9,7 @@ import ( ) // IPNetworks is a parameter that contains a list of IP networks. -type IPNetworks []*net.IPNet +type IPNetworks []net.IPNet // MarshalJSON implements json.Marshaler. func (d IPNetworks) MarshalJSON() ([]byte, error) { @@ -39,9 +39,17 @@ func (d *IPNetworks) UnmarshalJSON(b []byte) error { for _, t := range in { if _, ipnet, err := net.ParseCIDR(t); err == nil { - *d = append(*d, ipnet) + if ipv4 := ipnet.IP.To4(); ipv4 != nil { + *d = append(*d, net.IPNet{IP: ipv4, Mask: ipnet.Mask[len(ipnet.Mask)-4 : len(ipnet.Mask)]}) + } else { + *d = append(*d, *ipnet) + } } else if ip := net.ParseIP(t); ip != nil { - *d = append(*d, &net.IPNet{IP: ip, Mask: net.CIDRMask(len(ip)*8, len(ip)*8)}) + if ipv4 := ip.To4(); ipv4 != nil { + *d = append(*d, net.IPNet{IP: ipv4, Mask: net.CIDRMask(32, 32)}) + } else { + *d = append(*d, net.IPNet{IP: ip, Mask: net.CIDRMask(128, 128)}) + } } else { return fmt.Errorf("unable to parse IP/CIDR '%s'", t) } diff --git a/internal/conf/path.go b/internal/conf/path.go index 4ac29ccd4f40..6bf93adab694 100644 --- a/internal/conf/path.go +++ b/internal/conf/path.go @@ -11,7 +11,6 @@ import ( "time" "github.com/bluenviron/gortsplib/v4/pkg/base" - "github.com/bluenviron/gortsplib/v4/pkg/headers" ) var rePathName = regexp.MustCompile(`^[0-9a-zA-Z_\-/\.~]+$`) @@ -105,13 +104,13 @@ type Path struct { RecordSegmentDuration StringDuration `json:"recordSegmentDuration"` RecordDeleteAfter StringDuration `json:"recordDeleteAfter"` - // Authentication - PublishUser Credential `json:"publishUser"` - PublishPass Credential `json:"publishPass"` - PublishIPs IPNetworks `json:"publishIPs"` - ReadUser Credential `json:"readUser"` - ReadPass Credential `json:"readPass"` - ReadIPs IPNetworks `json:"readIPs"` + // Authentication (deprecated) + PublishUser *Credential `json:"publishUser,omitempty"` // deprecated + PublishPass *Credential `json:"publishPass,omitempty"` // deprecated + PublishIPs *IPNetworks `json:"publishIPs,omitempty"` // deprecated + ReadUser *Credential `json:"readUser,omitempty"` // deprecated + ReadPass *Credential `json:"readPass,omitempty"` // deprecated + ReadIPs *IPNetworks `json:"readIPs,omitempty"` // deprecated // Publisher source OverridePublisher bool `json:"overridePublisher"` @@ -250,7 +249,11 @@ func (pconf Path) Clone() *Path { return &dest } -func (pconf *Path) validate(conf *Conf, name string) error { +func (pconf *Path) validate( + conf *Conf, + name string, + deprecatedCredentialsMode bool, +) error { pconf.Name = name switch { @@ -375,39 +378,72 @@ func (pconf *Path) validate(conf *Conf, name string) error { } } - // Authentication + // Authentication (deprecated) - if (pconf.PublishUser != "" && pconf.PublishPass == "") || - (pconf.PublishUser == "" && pconf.PublishPass != "") { - return fmt.Errorf("read username and password must be both filled") - } - if pconf.PublishUser != "" && pconf.Source != "publisher" { - return fmt.Errorf("'publishUser' is useless when source is not 'publisher', since " + - "the stream is not provided by a publisher, but by a fixed source") - } - if len(pconf.PublishIPs) > 0 && pconf.Source != "publisher" { - return fmt.Errorf("'publishIPs' is useless when source is not 'publisher', since " + - "the stream is not provided by a publisher, but by a fixed source") - } - if (pconf.ReadUser != "" && pconf.ReadPass == "") || - (pconf.ReadUser == "" && pconf.ReadPass != "") { - return fmt.Errorf("read username and password must be both filled") - } - if contains(conf.RTSPAuthMethods, headers.AuthDigestMD5) { - if pconf.PublishUser.IsHashed() || - pconf.PublishPass.IsHashed() || - pconf.ReadUser.IsHashed() || - pconf.ReadPass.IsHashed() { - return fmt.Errorf("hashed credentials can't be used when the digest auth method is available") - } - } - if conf.ExternalAuthenticationURL != "" { - if pconf.PublishUser != "" || - len(pconf.PublishIPs) > 0 || - pconf.ReadUser != "" || - len(pconf.ReadIPs) > 0 { - return fmt.Errorf("credentials or IPs can't be used together with 'externalAuthenticationURL'") - } + if deprecatedCredentialsMode { + func() { + var user Credential = "any" + if pconf.PublishUser != nil { + user = *pconf.PublishUser + } + + var pass Credential + if pconf.PublishPass != nil { + pass = *pconf.PublishPass + } + + ips := IPNetworks{mustParseCIDR("0.0.0.0/0")} + if pconf.PublishIPs != nil { + ips = *pconf.PublishIPs + } + + pathName := name + if name == "all_others" || name == "all" { + pathName = "~^.*$" + } + + conf.AuthInternalUsers = append(conf.AuthInternalUsers, AuthInternalUser{ + User: user, + Pass: pass, + IPs: ips, + Permissions: []AuthInternalUserPermission{{ + Action: AuthActionPublish, + Path: pathName, + }}, + }) + }() + + func() { + var user Credential = "any" + if pconf.ReadUser != nil { + user = *pconf.ReadUser + } + + var pass Credential + if pconf.ReadPass != nil { + pass = *pconf.ReadPass + } + + ips := IPNetworks{mustParseCIDR("0.0.0.0/0")} + if pconf.ReadIPs != nil { + ips = *pconf.ReadIPs + } + + pathName := name + if name == "all_others" || name == "all" { + pathName = "~^.*$" + } + + conf.AuthInternalUsers = append(conf.AuthInternalUsers, AuthInternalUser{ + User: user, + Pass: pass, + IPs: ips, + Permissions: []AuthInternalUserPermission{{ + Action: AuthActionRead, + Path: pathName, + }}, + }) + }() } // Publisher source diff --git a/internal/conf/string_size.go b/internal/conf/string_size.go index e3a1dcc45d80..cd9f8df6ef6d 100644 --- a/internal/conf/string_size.go +++ b/internal/conf/string_size.go @@ -25,8 +25,8 @@ func (s *StringSize) UnmarshalJSON(b []byte) error { if err != nil { return err } - *s = StringSize(v) + return nil } diff --git a/internal/core/auth.go b/internal/core/auth.go deleted file mode 100644 index cf81eaa3b951..000000000000 --- a/internal/core/auth.go +++ /dev/null @@ -1,126 +0,0 @@ -package core - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/bluenviron/gortsplib/v4/pkg/auth" - "github.com/bluenviron/gortsplib/v4/pkg/headers" - "github.com/google/uuid" - - "github.com/bluenviron/mediamtx/internal/conf" - "github.com/bluenviron/mediamtx/internal/defs" -) - -func doExternalAuthentication( - ur string, - accessRequest defs.PathAccessRequest, -) error { - enc, _ := json.Marshal(struct { - IP string `json:"ip"` - User string `json:"user"` - Password string `json:"password"` - Path string `json:"path"` - Protocol string `json:"protocol"` - ID *uuid.UUID `json:"id"` - Action string `json:"action"` - Query string `json:"query"` - }{ - IP: accessRequest.IP.String(), - User: accessRequest.User, - Password: accessRequest.Pass, - Path: accessRequest.Name, - Protocol: string(accessRequest.Proto), - ID: accessRequest.ID, - Action: func() string { - if accessRequest.Publish { - return "publish" - } - return "read" - }(), - Query: accessRequest.Query, - }) - res, err := http.Post(ur, "application/json", bytes.NewReader(enc)) - if err != nil { - return err - } - defer res.Body.Close() - - if res.StatusCode < 200 || res.StatusCode > 299 { - if resBody, err := io.ReadAll(res.Body); err == nil && len(resBody) != 0 { - return fmt.Errorf("server replied with code %d: %s", res.StatusCode, string(resBody)) - } - return fmt.Errorf("server replied with code %d", res.StatusCode) - } - - return nil -} - -func doAuthentication( - externalAuthenticationURL string, - rtspAuthMethods conf.RTSPAuthMethods, - pathConf *conf.Path, - accessRequest defs.PathAccessRequest, -) error { - var rtspAuth headers.Authorization - if accessRequest.RTSPRequest != nil { - err := rtspAuth.Unmarshal(accessRequest.RTSPRequest.Header["Authorization"]) - if err == nil && rtspAuth.Method == headers.AuthBasic { - accessRequest.User = rtspAuth.BasicUser - accessRequest.Pass = rtspAuth.BasicPass - } - } - - if externalAuthenticationURL != "" { - err := doExternalAuthentication( - externalAuthenticationURL, - accessRequest, - ) - if err != nil { - return defs.AuthenticationError{Message: fmt.Sprintf("external authentication failed: %s", err)} - } - } - - var pathIPs conf.IPNetworks - var pathUser conf.Credential - var pathPass conf.Credential - - if accessRequest.Publish { - pathIPs = pathConf.PublishIPs - pathUser = pathConf.PublishUser - pathPass = pathConf.PublishPass - } else { - pathIPs = pathConf.ReadIPs - pathUser = pathConf.ReadUser - pathPass = pathConf.ReadPass - } - - if pathIPs != nil { - if !pathIPs.Contains(accessRequest.IP) { - return defs.AuthenticationError{Message: fmt.Sprintf("IP %s not allowed", accessRequest.IP)} - } - } - - if pathUser != "" { - if accessRequest.RTSPRequest != nil && rtspAuth.Method == headers.AuthDigestMD5 { - err := auth.Validate( - accessRequest.RTSPRequest, - string(pathUser), - string(pathPass), - accessRequest.RTSPBaseURL, - rtspAuthMethods, - "IPCAM", - accessRequest.RTSPNonce) - if err != nil { - return defs.AuthenticationError{Message: err.Error()} - } - } else if !pathUser.Check(accessRequest.User) || !pathPass.Check(accessRequest.Pass) { - return defs.AuthenticationError{Message: "invalid credentials"} - } - } - - return nil -} diff --git a/internal/core/auth_test.go b/internal/core/auth_test.go deleted file mode 100644 index 93f7e9641287..000000000000 --- a/internal/core/auth_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package core - -import ( - "context" - "encoding/json" - "net" - "net/http" - "testing" - - "github.com/bluenviron/gortsplib/v4/pkg/headers" - "github.com/bluenviron/mediamtx/internal/conf" - "github.com/bluenviron/mediamtx/internal/defs" - "github.com/stretchr/testify/require" -) - -type testHTTPAuthenticator struct { - *http.Server -} - -func (ts *testHTTPAuthenticator) initialize(t *testing.T, protocol string, action string) { - firstReceived := false - - ts.Server = &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) - - var user string - if action == "publish" { - user = "testpublisher" - } else { - user = "testreader" - } - - if in.IP != "127.0.0.1" || - in.User != user || - in.Password != "testpass" || - in.Path != "teststream" || - in.Protocol != protocol || - (firstReceived && in.ID == "") || - in.Action != action || - (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 ts.Server.Serve(ln) -} - -func (ts *testHTTPAuthenticator) close() { - ts.Server.Shutdown(context.Background()) -} - -func TestAuthSha256(t *testing.T) { - err := doAuthentication( - "", - conf.RTSPAuthMethods{headers.AuthBasic}, - &conf.Path{ - PublishUser: conf.Credential("sha256:rl3rgi4NcZkpAEcacZnQ2VuOfJ0FxAqCRaKB/SwdZoQ="), - PublishPass: conf.Credential("sha256:E9JJ8stBJ7QM+nV4ZoUCeHk/gU3tPFh/5YieiJp6n2w="), - }, - defs.PathAccessRequest{ - Name: "mypath", - Query: "", - Publish: true, - SkipAuth: false, - IP: net.ParseIP("127.0.0.1"), - User: "testuser", - Pass: "testpass", - Proto: defs.AuthProtocolRTSP, - ID: nil, - RTSPRequest: nil, - RTSPBaseURL: nil, - RTSPNonce: "", - }, - ) - require.NoError(t, err) -} - -func TestAuthArgon2(t *testing.T) { - err := doAuthentication( - "", - conf.RTSPAuthMethods{headers.AuthBasic}, - &conf.Path{ - PublishUser: conf.Credential( - "argon2:$argon2id$v=19$m=4096,t=3,p=1$MTIzNDU2Nzg$Ux/LWeTgJQPyfMMJo1myR64+o8rALHoPmlE1i/TR+58"), - PublishPass: conf.Credential( - "argon2:$argon2i$v=19$m=4096,t=3,p=1$MTIzNDU2Nzg$/mrZ42TiTv1mcPnpMUera5oi0SFYbbyueAbdx5sUvWo"), - }, - defs.PathAccessRequest{ - Name: "mypath", - Query: "", - Publish: true, - SkipAuth: false, - IP: net.ParseIP("127.0.0.1"), - User: "testuser", - Pass: "testpass", - Proto: defs.AuthProtocolRTSP, - ID: nil, - RTSPRequest: nil, - RTSPBaseURL: nil, - RTSPNonce: "", - }, - ) - require.NoError(t, err) -} - -func TestAuthExternal(t *testing.T) { - au := &testHTTPAuthenticator{} - au.initialize(t, "rtsp", "publish") - defer au.close() - - err := doAuthentication( - "http://127.0.0.1:9120/auth", - conf.RTSPAuthMethods{headers.AuthBasic}, - &conf.Path{}, - defs.PathAccessRequest{ - Name: "teststream", - Query: "param=value", - Publish: true, - SkipAuth: false, - IP: net.ParseIP("127.0.0.1"), - User: "testpublisher", - Pass: "testpass", - Proto: defs.AuthProtocolRTSP, - ID: nil, - RTSPRequest: nil, - RTSPBaseURL: nil, - RTSPNonce: "", - }, - ) - require.NoError(t, err) -} diff --git a/internal/core/core.go b/internal/core/core.go index 152b7e9c7cd5..06c14bc53bbd 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -17,6 +17,7 @@ import ( "github.com/gin-gonic/gin" "github.com/bluenviron/mediamtx/internal/api" + "github.com/bluenviron/mediamtx/internal/auth" "github.com/bluenviron/mediamtx/internal/conf" "github.com/bluenviron/mediamtx/internal/confwatcher" "github.com/bluenviron/mediamtx/internal/externalcmd" @@ -88,6 +89,7 @@ type Core struct { conf *conf.Conf logger *logger.Logger externalCmdPool *externalcmd.Pool + authManager *auth.Manager metrics *metrics.Metrics pprof *pprof.PPROF recordCleaner *record.Cleaner @@ -278,11 +280,24 @@ func (p *Core) createResources(initial bool) error { p.externalCmdPool = externalcmd.NewPool() } + if p.authManager == nil { + p.authManager = &auth.Manager{ + Method: p.conf.AuthMethod, + InternalUsers: p.conf.AuthInternalUsers, + HTTPAddress: p.conf.AuthHTTPAddress, + HTTPExclude: p.conf.AuthHTTPExclude, + JWTJWKS: p.conf.AuthJWTJWKS, + ReadTimeout: time.Duration(p.conf.ReadTimeout), + RTSPAuthMethods: p.conf.RTSPAuthMethods, + } + } + if p.conf.Metrics && p.metrics == nil { i := &metrics.Metrics{ Address: p.conf.MetricsAddress, ReadTimeout: p.conf.ReadTimeout, + AuthManager: p.authManager, Parent: p, } err := i.Initialize() @@ -297,6 +312,7 @@ func (p *Core) createResources(initial bool) error { i := &pprof.PPROF{ Address: p.conf.PPROFAddress, ReadTimeout: p.conf.ReadTimeout, + AuthManager: p.authManager, Parent: p, } err := i.Initialize() @@ -322,6 +338,7 @@ func (p *Core) createResources(initial bool) error { Address: p.conf.PlaybackAddress, ReadTimeout: p.conf.ReadTimeout, PathConfs: p.conf.Paths, + AuthManager: p.authManager, Parent: p, } err := i.Initialize() @@ -333,17 +350,16 @@ func (p *Core) createResources(initial bool) error { if p.pathManager == nil { p.pathManager = &pathManager{ - logLevel: p.conf.LogLevel, - externalAuthenticationURL: p.conf.ExternalAuthenticationURL, - rtspAddress: p.conf.RTSPAddress, - rtspAuthMethods: p.conf.RTSPAuthMethods, - readTimeout: p.conf.ReadTimeout, - writeTimeout: p.conf.WriteTimeout, - writeQueueSize: p.conf.WriteQueueSize, - udpMaxPayloadSize: p.conf.UDPMaxPayloadSize, - pathConfs: p.conf.Paths, - externalCmdPool: p.externalCmdPool, - parent: p, + logLevel: p.conf.LogLevel, + authManager: p.authManager, + rtspAddress: p.conf.RTSPAddress, + readTimeout: p.conf.ReadTimeout, + writeTimeout: p.conf.WriteTimeout, + writeQueueSize: p.conf.WriteQueueSize, + udpMaxPayloadSize: p.conf.UDPMaxPayloadSize, + pathConfs: p.conf.Paths, + externalCmdPool: p.externalCmdPool, + parent: p, } p.pathManager.initialize() @@ -596,6 +612,7 @@ func (p *Core) createResources(initial bool) error { Address: p.conf.APIAddress, ReadTimeout: p.conf.ReadTimeout, Conf: p.conf, + AuthManager: p.authManager, PathManager: p.pathManager, RTSPServer: p.rtspServer, RTSPSServer: p.rtspsServer, @@ -629,16 +646,29 @@ func (p *Core) closeResources(newConf *conf.Conf, calledByAPI bool) { !reflect.DeepEqual(newConf.LogDestinations, p.conf.LogDestinations) || newConf.LogFile != p.conf.LogFile + closeAuthManager := newConf == nil || + newConf.AuthMethod != p.conf.AuthMethod || + newConf.AuthHTTPAddress != p.conf.AuthHTTPAddress || + !reflect.DeepEqual(newConf.AuthHTTPExclude, p.conf.AuthHTTPExclude) || + newConf.AuthJWTJWKS != p.conf.AuthJWTJWKS || + newConf.ReadTimeout != p.conf.ReadTimeout || + !reflect.DeepEqual(newConf.RTSPAuthMethods, p.conf.RTSPAuthMethods) + if !closeAuthManager && !reflect.DeepEqual(newConf.AuthInternalUsers, p.conf.AuthInternalUsers) { + p.authManager.ReloadInternalUsers(newConf.AuthInternalUsers) + } + closeMetrics := newConf == nil || newConf.Metrics != p.conf.Metrics || newConf.MetricsAddress != p.conf.MetricsAddress || newConf.ReadTimeout != p.conf.ReadTimeout || + closeAuthManager || closeLogger closePPROF := newConf == nil || newConf.PPROF != p.conf.PPROF || newConf.PPROFAddress != p.conf.PPROFAddress || newConf.ReadTimeout != p.conf.ReadTimeout || + closeAuthManager || closeLogger closeRecorderCleaner := newConf == nil || @@ -649,6 +679,7 @@ func (p *Core) closeResources(newConf *conf.Conf, calledByAPI bool) { newConf.Playback != p.conf.Playback || newConf.PlaybackAddress != p.conf.PlaybackAddress || newConf.ReadTimeout != p.conf.ReadTimeout || + closeAuthManager || closeLogger if !closePlaybackServer && p.playbackServer != nil && !reflect.DeepEqual(newConf.Paths, p.conf.Paths) { p.playbackServer.ReloadPathConfs(newConf.Paths) @@ -656,7 +687,6 @@ func (p *Core) closeResources(newConf *conf.Conf, calledByAPI bool) { closePathManager := newConf == nil || newConf.LogLevel != p.conf.LogLevel || - newConf.ExternalAuthenticationURL != p.conf.ExternalAuthenticationURL || newConf.RTSPAddress != p.conf.RTSPAddress || !reflect.DeepEqual(newConf.RTSPAuthMethods, p.conf.RTSPAuthMethods) || newConf.ReadTimeout != p.conf.ReadTimeout || @@ -664,6 +694,7 @@ func (p *Core) closeResources(newConf *conf.Conf, calledByAPI bool) { newConf.WriteQueueSize != p.conf.WriteQueueSize || newConf.UDPMaxPayloadSize != p.conf.UDPMaxPayloadSize || closeMetrics || + closeAuthManager || closeLogger if !closePathManager && !reflect.DeepEqual(newConf.Paths, p.conf.Paths) { p.pathManager.ReloadPathConfs(newConf.Paths) @@ -802,6 +833,7 @@ func (p *Core) closeResources(newConf *conf.Conf, calledByAPI bool) { newConf.API != p.conf.API || newConf.APIAddress != p.conf.APIAddress || newConf.ReadTimeout != p.conf.ReadTimeout || + closeAuthManager || closePathManager || closeRTSPServer || closeRTSPSServer || @@ -919,6 +951,10 @@ func (p *Core) closeResources(newConf *conf.Conf, calledByAPI bool) { p.metrics = nil } + if closeAuthManager && p.authManager != nil { + p.authManager = nil + } + if newConf == nil && p.externalCmdPool != nil { p.Log(logger.Info, "waiting for running hooks") p.externalCmdPool.Close() diff --git a/internal/core/path_manager.go b/internal/core/path_manager.go index e2c5d46317f9..ae78ba1b1e23 100644 --- a/internal/core/path_manager.go +++ b/internal/core/path_manager.go @@ -6,6 +6,7 @@ import ( "sort" "sync" + "github.com/bluenviron/mediamtx/internal/auth" "github.com/bluenviron/mediamtx/internal/conf" "github.com/bluenviron/mediamtx/internal/defs" "github.com/bluenviron/mediamtx/internal/externalcmd" @@ -47,17 +48,16 @@ type pathManagerParent interface { } type pathManager struct { - logLevel conf.LogLevel - externalAuthenticationURL string - rtspAddress string - rtspAuthMethods conf.RTSPAuthMethods - readTimeout conf.StringDuration - writeTimeout conf.StringDuration - writeQueueSize int - udpMaxPayloadSize int - pathConfs map[string]*conf.Path - externalCmdPool *externalcmd.Pool - parent pathManagerParent + logLevel conf.LogLevel + authManager *auth.Manager + rtspAddress string + readTimeout conf.StringDuration + writeTimeout conf.StringDuration + writeQueueSize int + udpMaxPayloadSize int + pathConfs map[string]*conf.Path + externalCmdPool *externalcmd.Pool + parent pathManagerParent ctx context.Context ctxCancel func() @@ -236,8 +236,7 @@ func (pm *pathManager) doFindPathConf(req defs.PathFindPathConfReq) { return } - err = doAuthentication(pm.externalAuthenticationURL, pm.rtspAuthMethods, - pathConf, req.AccessRequest) + err = pm.authManager.Authenticate(req.AccessRequest.ToAuthRequest()) if err != nil { req.Res <- defs.PathFindPathConfRes{Err: err} return @@ -253,8 +252,7 @@ func (pm *pathManager) doDescribe(req defs.PathDescribeReq) { return } - err = doAuthentication(pm.externalAuthenticationURL, pm.rtspAuthMethods, - pathConf, req.AccessRequest) + err = pm.authManager.Authenticate(req.AccessRequest.ToAuthRequest()) if err != nil { req.Res <- defs.PathDescribeRes{Err: err} return @@ -276,8 +274,7 @@ func (pm *pathManager) doAddReader(req defs.PathAddReaderReq) { } if !req.AccessRequest.SkipAuth { - err = doAuthentication(pm.externalAuthenticationURL, pm.rtspAuthMethods, - pathConf, req.AccessRequest) + err = pm.authManager.Authenticate(req.AccessRequest.ToAuthRequest()) if err != nil { req.Res <- defs.PathAddReaderRes{Err: err} return @@ -300,8 +297,7 @@ func (pm *pathManager) doAddPublisher(req defs.PathAddPublisherReq) { } if !req.AccessRequest.SkipAuth { - err = doAuthentication(pm.externalAuthenticationURL, pm.rtspAuthMethods, - pathConf, req.AccessRequest) + err = pm.authManager.Authenticate(req.AccessRequest.ToAuthRequest()) if err != nil { req.Res <- defs.PathAddPublisherRes{Err: err} return diff --git a/internal/defs/auth.go b/internal/defs/auth.go deleted file mode 100644 index a0d708f9b4ff..000000000000 --- a/internal/defs/auth.go +++ /dev/null @@ -1,23 +0,0 @@ -package defs - -// AuthProtocol is a authentication protocol. -type AuthProtocol string - -// authentication protocols. -const ( - AuthProtocolRTSP AuthProtocol = "rtsp" - AuthProtocolRTMP AuthProtocol = "rtmp" - AuthProtocolHLS AuthProtocol = "hls" - AuthProtocolWebRTC AuthProtocol = "webrtc" - AuthProtocolSRT AuthProtocol = "srt" -) - -// AuthenticationError is a authentication error. -type AuthenticationError struct { - Message string -} - -// Error implements the error interface. -func (e AuthenticationError) Error() string { - return "authentication failed: " + e.Message -} diff --git a/internal/defs/path.go b/internal/defs/path.go index d9d98bb9a4a7..d5ee377b491e 100644 --- a/internal/defs/path.go +++ b/internal/defs/path.go @@ -8,6 +8,7 @@ import ( "github.com/bluenviron/gortsplib/v4/pkg/description" "github.com/google/uuid" + "github.com/bluenviron/mediamtx/internal/auth" "github.com/bluenviron/mediamtx/internal/conf" "github.com/bluenviron/mediamtx/internal/externalcmd" "github.com/bluenviron/mediamtx/internal/stream" @@ -45,13 +46,35 @@ type PathAccessRequest struct { IP net.IP User string Pass string - Proto AuthProtocol + Proto auth.Protocol ID *uuid.UUID RTSPRequest *base.Request RTSPBaseURL *base.URL RTSPNonce string } +// ToAuthRequest converts a path access request into an authentication request. +func (r *PathAccessRequest) ToAuthRequest() *auth.Request { + return &auth.Request{ + User: r.User, + Pass: r.Pass, + IP: r.IP, + Action: func() conf.AuthAction { + if r.Publish { + return conf.AuthActionPublish + } + return conf.AuthActionRead + }(), + Path: r.Name, + Protocol: r.Proto, + ID: r.ID, + Query: r.Query, + RTSPRequest: r.RTSPRequest, + RTSPBaseURL: r.RTSPBaseURL, + RTSPNonce: r.RTSPNonce, + } +} + // PathFindPathConfRes contains the response of FindPathConf(). type PathFindPathConfRes struct { Conf *conf.Path diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 4a2520fbcf0b..b6fe11e6c000 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -3,6 +3,7 @@ package metrics import ( "io" + "net" "net/http" "reflect" "strconv" @@ -12,6 +13,7 @@ import ( "github.com/gin-gonic/gin" "github.com/bluenviron/mediamtx/internal/api" + "github.com/bluenviron/mediamtx/internal/auth" "github.com/bluenviron/mediamtx/internal/conf" "github.com/bluenviron/mediamtx/internal/logger" "github.com/bluenviron/mediamtx/internal/protocols/httpp" @@ -38,6 +40,7 @@ type metricsParent interface { type Metrics struct { Address string ReadTimeout conf.StringDuration + AuthManager *auth.Manager Parent metricsParent httpServer *httpp.WrappedServer @@ -57,7 +60,7 @@ func (m *Metrics) Initialize() error { router := gin.New() router.SetTrustedProxies(nil) //nolint:errcheck - router.GET("/metrics", m.onMetrics) + router.GET("/metrics", m.mwAuth, m.onMetrics) network, address := restrictnetwork.Restrict("tcp", m.Address) @@ -91,6 +94,30 @@ func (m *Metrics) Log(level logger.Level, format string, args ...interface{}) { m.Parent.Log(level, "[metrics] "+format, args...) } +func (m *Metrics) mwAuth(ctx *gin.Context) { + user, pass, hasCredentials := ctx.Request.BasicAuth() + + err := m.AuthManager.Authenticate(&auth.Request{ + User: user, + Pass: pass, + IP: net.ParseIP(ctx.ClientIP()), + Action: conf.AuthActionMetrics, + }) + if err != nil { + if !hasCredentials { + ctx.Header("WWW-Authenticate", `Basic realm="mediamtx"`) + ctx.AbortWithStatus(http.StatusUnauthorized) + return + } + + // wait some seconds to mitigate brute force attacks + <-time.After(auth.PauseAfterError) + + ctx.Writer.WriteHeader(http.StatusUnauthorized) + return + } +} + func (m *Metrics) onMetrics(ctx *gin.Context) { out := "" diff --git a/internal/playback/server.go b/internal/playback/server.go index b3e4fc2f94b8..cea043913557 100644 --- a/internal/playback/server.go +++ b/internal/playback/server.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "github.com/bluenviron/mediamtx/internal/auth" "github.com/bluenviron/mediamtx/internal/conf" "github.com/bluenviron/mediamtx/internal/logger" "github.com/bluenviron/mediamtx/internal/protocols/httpp" @@ -57,6 +58,7 @@ type Server struct { Address string ReadTimeout conf.StringDuration PathConfs map[string]*conf.Path + AuthManager *auth.Manager Parent logger.Writer httpServer *httpp.WrappedServer @@ -128,9 +130,45 @@ func (p *Server) safeFindPathConf(name string) (*conf.Path, error) { return pathConf, err } +func (p *Server) doAuth(ctx *gin.Context, pathName string) bool { + user, pass, hasCredentials := ctx.Request.BasicAuth() + + err := p.AuthManager.Authenticate(&auth.Request{ + User: user, + Pass: pass, + IP: net.ParseIP(ctx.ClientIP()), + Action: conf.AuthActionPlayback, + Path: pathName, + }) + if err != nil { + if !hasCredentials { + ctx.Header("WWW-Authenticate", `Basic realm="mediamtx"`) + ctx.Writer.WriteHeader(http.StatusUnauthorized) + return false + } + + var terr auth.Error + errors.As(err, &terr) + + p.Log(logger.Info, "connection %v failed to authenticate: %v", httpp.RemoteAddr(ctx), terr.Message) + + // wait some seconds to mitigate brute force attacks + <-time.After(auth.PauseAfterError) + + ctx.Writer.WriteHeader(http.StatusUnauthorized) + return false + } + + return true +} + func (p *Server) onList(ctx *gin.Context) { pathName := ctx.Query("path") + if !p.doAuth(ctx, pathName) { + return + } + pathConf, err := p.safeFindPathConf(pathName) if err != nil { p.writeError(ctx, http.StatusBadRequest, err) @@ -182,6 +220,10 @@ func (p *Server) onList(ctx *gin.Context) { func (p *Server) onGet(ctx *gin.Context) { pathName := ctx.Query("path") + if !p.doAuth(ctx, pathName) { + return + } + start, err := time.Parse(time.RFC3339, ctx.Query("start")) if err != nil { p.writeError(ctx, http.StatusBadRequest, fmt.Errorf("invalid start: %w", err)) diff --git a/internal/playback/server_test.go b/internal/playback/server_test.go index 89f9adb48620..f56d6ec09bc4 100644 --- a/internal/playback/server_test.go +++ b/internal/playback/server_test.go @@ -12,6 +12,7 @@ import ( "github.com/bluenviron/mediacommon/pkg/formats/fmp4" "github.com/bluenviron/mediacommon/pkg/formats/fmp4/seekablebuffer" + "github.com/bluenviron/mediamtx/internal/auth" "github.com/bluenviron/mediamtx/internal/conf" "github.com/bluenviron/mediamtx/internal/test" "github.com/stretchr/testify/require" @@ -120,6 +121,23 @@ func writeSegment2(t *testing.T, fpath string) { require.NoError(t, err) } +var authManager = &auth.Manager{ + Method: conf.AuthMethodInternal, + InternalUsers: []conf.AuthInternalUser{ + { + User: "myuser", + Pass: "mypass", + Permissions: []conf.AuthInternalUserPermission{ + { + Action: conf.AuthActionPlayback, + Path: "mypath", + }, + }, + }, + }, + RTSPAuthMethods: nil, +} + func TestServerGet(t *testing.T) { dir, err := os.MkdirTemp("", "mediamtx-playback") require.NoError(t, err) @@ -140,24 +158,22 @@ func TestServerGet(t *testing.T) { RecordPath: filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f"), }, }, - Parent: &test.NilLogger{}, + AuthManager: authManager, + Parent: &test.NilLogger{}, } err = s.Initialize() require.NoError(t, err) defer s.Close() + u, err := url.Parse("http://myuser:mypass@localhost:9996/get") + require.NoError(t, err) + v := url.Values{} v.Set("path", "mypath") v.Set("start", time.Date(2008, 11, 0o7, 11, 23, 1, 500000000, time.Local).Format(time.RFC3339Nano)) v.Set("duration", "2") v.Set("format", "fmp4") - - u := &url.URL{ - Scheme: "http", - Host: "localhost:9996", - Path: "/get", - RawQuery: v.Encode(), - } + u.RawQuery = v.Encode() req, err := http.NewRequest(http.MethodGet, u.String(), nil) require.NoError(t, err) @@ -234,21 +250,19 @@ func TestServerList(t *testing.T) { RecordPath: filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f"), }, }, - Parent: &test.NilLogger{}, + AuthManager: authManager, + Parent: &test.NilLogger{}, } err = s.Initialize() require.NoError(t, err) defer s.Close() + u, err := url.Parse("http://myuser:mypass@localhost:9996/list") + require.NoError(t, err) + v := url.Values{} v.Set("path", "mypath") - - u := &url.URL{ - Scheme: "http", - Host: "localhost:9996", - Path: "/list", - RawQuery: v.Encode(), - } + u.RawQuery = v.Encode() req, err := http.NewRequest(http.MethodGet, u.String(), nil) require.NoError(t, err) diff --git a/internal/pprof/pprof.go b/internal/pprof/pprof.go index a15bc028992f..55ada0a97bac 100644 --- a/internal/pprof/pprof.go +++ b/internal/pprof/pprof.go @@ -2,12 +2,15 @@ package pprof import ( + "net" "net/http" + "strings" "time" // start pprof _ "net/http/pprof" + "github.com/bluenviron/mediamtx/internal/auth" "github.com/bluenviron/mediamtx/internal/conf" "github.com/bluenviron/mediamtx/internal/logger" "github.com/bluenviron/mediamtx/internal/protocols/httpp" @@ -22,6 +25,7 @@ type pprofParent interface { type PPROF struct { Address string ReadTimeout conf.StringDuration + AuthManager *auth.Manager Parent pprofParent httpServer *httpp.WrappedServer @@ -38,7 +42,7 @@ func (pp *PPROF) Initialize() error { time.Duration(pp.ReadTimeout), "", "", - http.DefaultServeMux, + pp, pp, ) if err != nil { @@ -60,3 +64,31 @@ func (pp *PPROF) Close() { func (pp *PPROF) Log(level logger.Level, format string, args ...interface{}) { pp.Parent.Log(level, "[pprof] "+format, args...) } + +func (pp *PPROF) ServeHTTP(w http.ResponseWriter, r *http.Request) { + user, pass, hasCredentials := r.BasicAuth() + + ip, _, _ := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)) + + err := pp.AuthManager.Authenticate(&auth.Request{ + User: user, + Pass: pass, + IP: net.ParseIP(ip), + Action: conf.AuthActionMetrics, + }) + if err != nil { + if !hasCredentials { + w.Header().Set("WWW-Authenticate", `Basic realm="mediamtx"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + + // wait some seconds to mitigate brute force attacks + <-time.After(auth.PauseAfterError) + + w.WriteHeader(http.StatusUnauthorized) + return + } + + http.DefaultServeMux.ServeHTTP(w, r) +} diff --git a/internal/servers/hls/http_server.go b/internal/servers/hls/http_server.go index 3f85aef55009..a297373b7e59 100644 --- a/internal/servers/hls/http_server.go +++ b/internal/servers/hls/http_server.go @@ -12,6 +12,7 @@ import ( "github.com/gin-gonic/gin" + "github.com/bluenviron/mediamtx/internal/auth" "github.com/bluenviron/mediamtx/internal/conf" "github.com/bluenviron/mediamtx/internal/defs" "github.com/bluenviron/mediamtx/internal/logger" @@ -19,10 +20,6 @@ import ( "github.com/bluenviron/mediamtx/internal/restrictnetwork" ) -const ( - pauseAfterAuthError = 2 * time.Second -) - //go:generate go run ./hlsjsdownloader //go:embed index.html @@ -158,11 +155,11 @@ func (s *httpServer) onRequest(ctx *gin.Context) { IP: net.ParseIP(ctx.ClientIP()), User: user, Pass: pass, - Proto: defs.AuthProtocolHLS, + Proto: auth.ProtocolHLS, }, }) if err != nil { - var terr defs.AuthenticationError + var terr auth.Error if errors.As(err, &terr) { if !hasCredentials { ctx.Header("WWW-Authenticate", `Basic realm="mediamtx"`) @@ -173,7 +170,7 @@ func (s *httpServer) onRequest(ctx *gin.Context) { s.Log(logger.Info, "connection %v failed to authenticate: %v", httpp.RemoteAddr(ctx), terr.Message) // wait some seconds to mitigate brute force attacks - <-time.After(pauseAfterAuthError) + <-time.After(auth.PauseAfterError) ctx.Writer.WriteHeader(http.StatusUnauthorized) return diff --git a/internal/servers/hls/server_test.go b/internal/servers/hls/server_test.go index d1a172f204b4..3e352053008a 100644 --- a/internal/servers/hls/server_test.go +++ b/internal/servers/hls/server_test.go @@ -10,6 +10,7 @@ import ( "github.com/bluenviron/gohlslib/pkg/codecs" "github.com/bluenviron/gortsplib/v4/pkg/description" "github.com/bluenviron/mediacommon/pkg/codecs/h264" + "github.com/bluenviron/mediamtx/internal/auth" "github.com/bluenviron/mediamtx/internal/conf" "github.com/bluenviron/mediamtx/internal/defs" "github.com/bluenviron/mediamtx/internal/externalcmd" @@ -50,7 +51,10 @@ type dummyPathManager struct { stream *stream.Stream } -func (pm *dummyPathManager) FindPathConf(_ defs.PathFindPathConfReq) (*conf.Path, error) { +func (pm *dummyPathManager) FindPathConf(req defs.PathFindPathConfReq) (*conf.Path, error) { + if req.AccessRequest.User != "myuser" || req.AccessRequest.Pass != "mypass" { + return nil, auth.Error{} + } return &conf.Path{}, nil } @@ -93,7 +97,7 @@ func TestServerNotFound(t *testing.T) { hc := &http.Client{Transport: &http.Transport{}} func() { - req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8888/nonexisting/", nil) + req, err := http.NewRequest(http.MethodGet, "http://myuser:mypass@127.0.0.1:8888/nonexisting/", nil) require.NoError(t, err) res, err := hc.Do(req) @@ -103,7 +107,7 @@ func TestServerNotFound(t *testing.T) { }() func() { - req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8888/nonexisting/index.m3u8", nil) + req, err := http.NewRequest(http.MethodGet, "http://myuser:mypass@127.0.0.1:8888/nonexisting/index.m3u8", nil) require.NoError(t, err) res, err := hc.Do(req) @@ -153,7 +157,7 @@ func TestServerRead(t *testing.T) { defer s.Close() c := &gohlslib.Client{ - URI: "http://127.0.0.1:8888/mystream/index.m3u8", + URI: "http://myuser:mypass@127.0.0.1:8888/mystream/index.m3u8", } recv := make(chan struct{}) @@ -254,7 +258,7 @@ func TestServerRead(t *testing.T) { } c := &gohlslib.Client{ - URI: "http://127.0.0.1:8888/mystream/index.m3u8", + URI: "http://myuser:mypass@127.0.0.1:8888/mystream/index.m3u8", } recv := make(chan struct{}) diff --git a/internal/servers/rtmp/conn.go b/internal/servers/rtmp/conn.go index f4b4774b9b2b..2b7b8c02c940 100644 --- a/internal/servers/rtmp/conn.go +++ b/internal/servers/rtmp/conn.go @@ -18,6 +18,7 @@ import ( "github.com/google/uuid" "github.com/bluenviron/mediamtx/internal/asyncwriter" + "github.com/bluenviron/mediamtx/internal/auth" "github.com/bluenviron/mediamtx/internal/conf" "github.com/bluenviron/mediamtx/internal/defs" "github.com/bluenviron/mediamtx/internal/externalcmd" @@ -28,10 +29,6 @@ import ( "github.com/bluenviron/mediamtx/internal/unit" ) -const ( - pauseAfterAuthError = 2 * time.Second -) - var errNoSupportedCodecs = errors.New( "the stream doesn't contain any supported codec, which are currently H264, MPEG-4 Audio, MPEG-1/2 Audio") @@ -176,15 +173,15 @@ func (c *conn) runRead(conn *rtmp.Conn, u *url.URL) error { IP: c.ip(), User: query.Get("user"), Pass: query.Get("pass"), - Proto: defs.AuthProtocolRTMP, + Proto: auth.ProtocolRTMP, ID: &c.uuid, }, }) if err != nil { - var terr defs.AuthenticationError + var terr auth.Error if errors.As(err, &terr) { // wait some seconds to mitigate brute force attacks - <-time.After(pauseAfterAuthError) + <-time.After(auth.PauseAfterError) return terr } return err @@ -405,15 +402,15 @@ func (c *conn) runPublish(conn *rtmp.Conn, u *url.URL) error { IP: c.ip(), User: query.Get("user"), Pass: query.Get("pass"), - Proto: defs.AuthProtocolRTMP, + Proto: auth.ProtocolRTMP, ID: &c.uuid, }, }) if err != nil { - var terr defs.AuthenticationError + var terr auth.Error if errors.As(err, &terr) { // wait some seconds to mitigate brute force attacks - <-time.After(pauseAfterAuthError) + <-time.After(auth.PauseAfterError) return terr } return err diff --git a/internal/servers/rtmp/server_test.go b/internal/servers/rtmp/server_test.go index aa92e06e8a48..24a1b8ef5eb0 100644 --- a/internal/servers/rtmp/server_test.go +++ b/internal/servers/rtmp/server_test.go @@ -10,6 +10,7 @@ import ( "github.com/bluenviron/gortsplib/v4/pkg/description" "github.com/bluenviron/mediamtx/internal/asyncwriter" + "github.com/bluenviron/mediamtx/internal/auth" "github.com/bluenviron/mediamtx/internal/conf" "github.com/bluenviron/mediamtx/internal/defs" "github.com/bluenviron/mediamtx/internal/externalcmd" @@ -65,11 +66,17 @@ type dummyPathManager struct { path *dummyPath } -func (pm *dummyPathManager) AddPublisher(_ defs.PathAddPublisherReq) (defs.Path, error) { +func (pm *dummyPathManager) AddPublisher(req defs.PathAddPublisherReq) (defs.Path, error) { + if req.AccessRequest.User != "myuser" || req.AccessRequest.Pass != "mypass" { + return nil, auth.Error{} + } return pm.path, nil } -func (pm *dummyPathManager) AddReader(_ defs.PathAddReaderReq) (defs.Path, *stream.Stream, error) { +func (pm *dummyPathManager) AddReader(req defs.PathAddReaderReq) (defs.Path, *stream.Stream, error) { + if req.AccessRequest.User != "myuser" || req.AccessRequest.Pass != "mypass" { + return nil, nil, auth.Error{} + } return pm.path, pm.path.stream, nil } @@ -119,7 +126,7 @@ func TestServerPublish(t *testing.T) { require.NoError(t, err) defer s.Close() - u, err := url.Parse("rtmp://127.0.0.1:1935/teststream?user=testpublisher&pass=testpass¶m=value") + u, err := url.Parse("rtmp://127.0.0.1:1935/teststream?user=myuser&pass=mypass¶m=value") require.NoError(t, err) nconn, err := func() (net.Conn, error) { @@ -221,7 +228,7 @@ func TestServerRead(t *testing.T) { require.NoError(t, err) defer s.Close() - u, err := url.Parse("rtmp://127.0.0.1:1935/teststream?user=testreader&pass=testpass¶m=value") + u, err := url.Parse("rtmp://127.0.0.1:1935/teststream?user=myuser&pass=mypass¶m=value") require.NoError(t, err) nconn, err := func() (net.Conn, error) { diff --git a/internal/servers/rtsp/conn.go b/internal/servers/rtsp/conn.go index edd725e6ed55..daed394ef416 100644 --- a/internal/servers/rtsp/conn.go +++ b/internal/servers/rtsp/conn.go @@ -7,11 +7,12 @@ import ( "time" "github.com/bluenviron/gortsplib/v4" - "github.com/bluenviron/gortsplib/v4/pkg/auth" + rtspauth "github.com/bluenviron/gortsplib/v4/pkg/auth" "github.com/bluenviron/gortsplib/v4/pkg/base" "github.com/bluenviron/gortsplib/v4/pkg/headers" "github.com/google/uuid" + "github.com/bluenviron/mediamtx/internal/auth" "github.com/bluenviron/mediamtx/internal/conf" "github.com/bluenviron/mediamtx/internal/defs" "github.com/bluenviron/mediamtx/internal/externalcmd" @@ -20,7 +21,7 @@ import ( ) const ( - pauseAfterAuthError = 2 * time.Second + rtspAuthRealm = "IPCAM" ) type conn struct { @@ -118,7 +119,7 @@ func (c *conn) onDescribe(ctx *gortsplib.ServerHandlerOnDescribeCtx, if c.authNonce == "" { var err error - c.authNonce, err = auth.GenerateNonce() + c.authNonce, err = rtspauth.GenerateNonce() if err != nil { return &base.Response{ StatusCode: base.StatusInternalServerError, @@ -131,7 +132,7 @@ func (c *conn) onDescribe(ctx *gortsplib.ServerHandlerOnDescribeCtx, Name: ctx.Path, Query: ctx.Query, IP: c.ip(), - Proto: defs.AuthProtocolRTSP, + Proto: auth.ProtocolRTSP, ID: &c.uuid, RTSPRequest: ctx.Request, RTSPNonce: c.authNonce, @@ -139,7 +140,7 @@ func (c *conn) onDescribe(ctx *gortsplib.ServerHandlerOnDescribeCtx, }) if res.Err != nil { - var terr defs.AuthenticationError + var terr auth.Error if errors.As(res.Err, &terr) { res, err := c.handleAuthError(terr) return res, nil, err @@ -191,13 +192,13 @@ func (c *conn) handleAuthError(authErr error) (*base.Response, error) { return &base.Response{ StatusCode: base.StatusUnauthorized, Header: base.Header{ - "WWW-Authenticate": auth.GenerateWWWAuthenticate(c.authMethods, "IPCAM", c.authNonce), + "WWW-Authenticate": rtspauth.GenerateWWWAuthenticate(c.authMethods, rtspAuthRealm, c.authNonce), }, }, nil } // wait some seconds to mitigate brute force attacks - <-time.After(pauseAfterAuthError) + <-time.After(auth.PauseAfterError) return &base.Response{ StatusCode: base.StatusUnauthorized, diff --git a/internal/servers/rtsp/server_test.go b/internal/servers/rtsp/server_test.go index 618d1ba8072e..943910b6c70d 100644 --- a/internal/servers/rtsp/server_test.go +++ b/internal/servers/rtsp/server_test.go @@ -48,7 +48,9 @@ func (p *dummyPath) StartPublisher(req defs.PathStartPublisherReq) (*stream.Stre if err != nil { return nil, err } + close(p.streamCreated) + return p.stream, nil } @@ -123,7 +125,7 @@ func TestServerPublish(t *testing.T) { media0 := test.UniqueMediaH264() err = source.StartRecording( - "rtsp://testpublisher:testpass@127.0.0.1:8557/teststream?param=value", + "rtsp://myuser:mypass@127.0.0.1:8557/teststream?param=value", &description.Session{Medias: []*description.Media{media0}}) require.NoError(t, err) defer source.Close() @@ -211,7 +213,7 @@ func TestServerRead(t *testing.T) { reader := gortsplib.Client{} - u, err := base.ParseURL("rtsp://testreader:testpass@127.0.0.1:8557/teststream?param=value") + u, err := base.ParseURL("rtsp://myuser:mypass@127.0.0.1:8557/teststream?param=value") require.NoError(t, err) err = reader.Start(u.Scheme, u.Host) diff --git a/internal/servers/rtsp/session.go b/internal/servers/rtsp/session.go index 2581b43b0944..bc705ba88b67 100644 --- a/internal/servers/rtsp/session.go +++ b/internal/servers/rtsp/session.go @@ -9,11 +9,12 @@ import ( "time" "github.com/bluenviron/gortsplib/v4" - "github.com/bluenviron/gortsplib/v4/pkg/auth" + rtspauth "github.com/bluenviron/gortsplib/v4/pkg/auth" "github.com/bluenviron/gortsplib/v4/pkg/base" "github.com/google/uuid" "github.com/pion/rtp" + "github.com/bluenviron/mediamtx/internal/auth" "github.com/bluenviron/mediamtx/internal/conf" "github.com/bluenviron/mediamtx/internal/defs" "github.com/bluenviron/mediamtx/internal/externalcmd" @@ -102,7 +103,7 @@ func (s *session) onAnnounce(c *conn, ctx *gortsplib.ServerHandlerOnAnnounceCtx) if c.authNonce == "" { var err error - c.authNonce, err = auth.GenerateNonce() + c.authNonce, err = rtspauth.GenerateNonce() if err != nil { return &base.Response{ StatusCode: base.StatusInternalServerError, @@ -117,7 +118,7 @@ func (s *session) onAnnounce(c *conn, ctx *gortsplib.ServerHandlerOnAnnounceCtx) Query: ctx.Query, Publish: true, IP: c.ip(), - Proto: defs.AuthProtocolRTSP, + Proto: auth.ProtocolRTSP, ID: &c.uuid, RTSPRequest: ctx.Request, RTSPBaseURL: nil, @@ -125,7 +126,7 @@ func (s *session) onAnnounce(c *conn, ctx *gortsplib.ServerHandlerOnAnnounceCtx) }, }) if err != nil { - var terr defs.AuthenticationError + var terr auth.Error if errors.As(err, &terr) { return c.handleAuthError(terr) } @@ -187,7 +188,7 @@ func (s *session) onSetup(c *conn, ctx *gortsplib.ServerHandlerOnSetupCtx, if c.authNonce == "" { var err error - c.authNonce, err = auth.GenerateNonce() + c.authNonce, err = rtspauth.GenerateNonce() if err != nil { return &base.Response{ StatusCode: base.StatusInternalServerError, @@ -201,7 +202,7 @@ func (s *session) onSetup(c *conn, ctx *gortsplib.ServerHandlerOnSetupCtx, Name: ctx.Path, Query: ctx.Query, IP: c.ip(), - Proto: defs.AuthProtocolRTSP, + Proto: auth.ProtocolRTSP, ID: &c.uuid, RTSPRequest: ctx.Request, RTSPBaseURL: baseURL, @@ -209,7 +210,7 @@ func (s *session) onSetup(c *conn, ctx *gortsplib.ServerHandlerOnSetupCtx, }, }) if err != nil { - var terr defs.AuthenticationError + var terr auth.Error if errors.As(err, &terr) { res, err := c.handleAuthError(terr) return res, nil, err diff --git a/internal/servers/srt/conn.go b/internal/servers/srt/conn.go index 9ffd360a4f35..b227fe4fcd97 100644 --- a/internal/servers/srt/conn.go +++ b/internal/servers/srt/conn.go @@ -15,6 +15,7 @@ import ( "github.com/google/uuid" "github.com/bluenviron/mediamtx/internal/asyncwriter" + "github.com/bluenviron/mediamtx/internal/auth" "github.com/bluenviron/mediamtx/internal/conf" "github.com/bluenviron/mediamtx/internal/defs" "github.com/bluenviron/mediamtx/internal/externalcmd" @@ -24,10 +25,6 @@ import ( "github.com/bluenviron/mediamtx/internal/stream" ) -const ( - pauseAfterAuthError = 2 * time.Second -) - func srtCheckPassphrase(connReq srt.ConnRequest, passphrase string) error { if passphrase == "" { return nil @@ -171,16 +168,16 @@ func (c *conn) runPublish(req srtNewConnReq, streamID *streamID) (bool, error) { Publish: true, User: streamID.user, Pass: streamID.pass, - Proto: defs.AuthProtocolSRT, + Proto: auth.ProtocolSRT, ID: &c.uuid, Query: streamID.query, }, }) if err != nil { - var terr defs.AuthenticationError + var terr auth.Error if errors.As(err, &terr) { // wait some seconds to mitigate brute force attacks - <-time.After(pauseAfterAuthError) + <-time.After(auth.PauseAfterError) return false, terr } return false, err @@ -267,16 +264,16 @@ func (c *conn) runRead(req srtNewConnReq, streamID *streamID) (bool, error) { IP: c.ip(), User: streamID.user, Pass: streamID.pass, - Proto: defs.AuthProtocolSRT, + Proto: auth.ProtocolSRT, ID: &c.uuid, Query: streamID.query, }, }) if err != nil { - var terr defs.AuthenticationError + var terr auth.Error if errors.As(err, &terr) { // wait some seconds to mitigate brute force attacks - <-time.After(pauseAfterAuthError) + <-time.After(auth.PauseAfterError) return false, err } return false, err diff --git a/internal/servers/srt/server_test.go b/internal/servers/srt/server_test.go index 7c637e1fdd4b..b3b238a67bd6 100644 --- a/internal/servers/srt/server_test.go +++ b/internal/servers/srt/server_test.go @@ -8,6 +8,7 @@ import ( "github.com/bluenviron/gortsplib/v4/pkg/description" "github.com/bluenviron/mediacommon/pkg/formats/mpegts" "github.com/bluenviron/mediamtx/internal/asyncwriter" + "github.com/bluenviron/mediamtx/internal/auth" "github.com/bluenviron/mediamtx/internal/conf" "github.com/bluenviron/mediamtx/internal/defs" "github.com/bluenviron/mediamtx/internal/externalcmd" @@ -63,11 +64,17 @@ type dummyPathManager struct { path *dummyPath } -func (pm *dummyPathManager) AddPublisher(_ defs.PathAddPublisherReq) (defs.Path, error) { +func (pm *dummyPathManager) AddPublisher(req defs.PathAddPublisherReq) (defs.Path, error) { + if req.AccessRequest.User != "myuser" || req.AccessRequest.Pass != "mypass" { + return nil, auth.Error{} + } return pm.path, nil } -func (pm *dummyPathManager) AddReader(_ defs.PathAddReaderReq) (defs.Path, *stream.Stream, error) { +func (pm *dummyPathManager) AddReader(req defs.PathAddReaderReq) (defs.Path, *stream.Stream, error) { + if req.AccessRequest.User != "myuser" || req.AccessRequest.Pass != "mypass" { + return nil, nil, auth.Error{} + } return pm.path, pm.path.stream, nil } @@ -99,7 +106,7 @@ func TestServerPublish(t *testing.T) { require.NoError(t, err) defer s.Close() - u := "srt://localhost:8890?streamid=publish:mypath" + u := "srt://localhost:8890?streamid=publish:mypath:myuser:mypass" srtConf := srt.DefaultConfig() address, err := srtConf.UnmarshalURL(u) @@ -198,7 +205,7 @@ func TestServerRead(t *testing.T) { require.NoError(t, err) defer s.Close() - u := "srt://localhost:8890?streamid=read:mypath" + u := "srt://localhost:8890?streamid=read:mypath:myuser:mypass" srtConf := srt.DefaultConfig() address, err := srtConf.UnmarshalURL(u) diff --git a/internal/servers/webrtc/http_server.go b/internal/servers/webrtc/http_server.go index e5468017ed93..95bddf2b483b 100644 --- a/internal/servers/webrtc/http_server.go +++ b/internal/servers/webrtc/http_server.go @@ -14,6 +14,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/bluenviron/mediamtx/internal/auth" "github.com/bluenviron/mediamtx/internal/conf" "github.com/bluenviron/mediamtx/internal/defs" "github.com/bluenviron/mediamtx/internal/logger" @@ -117,11 +118,11 @@ func (s *httpServer) checkAuthOutsideSession(ctx *gin.Context, path string, publ IP: net.ParseIP(ctx.ClientIP()), User: user, Pass: pass, - Proto: defs.AuthProtocolWebRTC, + Proto: auth.ProtocolWebRTC, }, }) if err != nil { - var terr defs.AuthenticationError + var terr auth.Error if errors.As(err, &terr) { if !hasCredentials { ctx.Header("WWW-Authenticate", `Basic realm="mediamtx"`) @@ -132,7 +133,7 @@ func (s *httpServer) checkAuthOutsideSession(ctx *gin.Context, path string, publ s.Log(logger.Info, "connection %v failed to authenticate: %v", httpp.RemoteAddr(ctx), terr.Message) // wait some seconds to mitigate brute force attacks - <-time.After(pauseAfterAuthError) + <-time.After(auth.PauseAfterError) writeError(ctx, http.StatusUnauthorized, terr) return false diff --git a/internal/servers/webrtc/server.go b/internal/servers/webrtc/server.go index 36952ecd2c43..4ac0d7da964c 100644 --- a/internal/servers/webrtc/server.go +++ b/internal/servers/webrtc/server.go @@ -30,7 +30,6 @@ import ( ) const ( - pauseAfterAuthError = 2 * time.Second webrtcTurnSecretExpiration = 24 * 3600 * time.Second webrtcPayloadMaxSize = 1188 // 1200 - 12 (RTP header) ) diff --git a/internal/servers/webrtc/server_test.go b/internal/servers/webrtc/server_test.go index e0f89a7b3ce8..05d1a79ffa83 100644 --- a/internal/servers/webrtc/server_test.go +++ b/internal/servers/webrtc/server_test.go @@ -10,6 +10,7 @@ import ( "github.com/bluenviron/gortsplib/v4/pkg/description" "github.com/bluenviron/mediamtx/internal/asyncwriter" + "github.com/bluenviron/mediamtx/internal/auth" "github.com/bluenviron/mediamtx/internal/conf" "github.com/bluenviron/mediamtx/internal/defs" "github.com/bluenviron/mediamtx/internal/externalcmd" @@ -71,7 +72,10 @@ type dummyPathManager struct { path *dummyPath } -func (pm *dummyPathManager) FindPathConf(_ defs.PathFindPathConfReq) (*conf.Path, error) { +func (pm *dummyPathManager) FindPathConf(req defs.PathFindPathConfReq) (*conf.Path, error) { + if req.AccessRequest.User != "myuser" || req.AccessRequest.Pass != "mypass" { + return nil, auth.Error{} + } return &conf.Path{}, nil } @@ -114,7 +118,7 @@ func TestServerStaticPages(t *testing.T) { for _, path := range []string{"/stream", "/stream/publish", "/publish"} { func() { - req, err := http.NewRequest(http.MethodGet, "http://localhost:8886"+path, nil) + req, err := http.NewRequest(http.MethodGet, "http://myuser:mypass@localhost:8886"+path, nil) require.NoError(t, err) res, err := hc.Do(req) @@ -175,8 +179,7 @@ func TestServerPublish(t *testing.T) { require.Equal(t, false, ok) }() - ur := "http://" - ur += "localhost:8886/teststream/whip?param=value" + ur := "http://myuser:mypass@localhost:8886/teststream/whip?param=value" su, err := url.Parse(ur) require.NoError(t, err) @@ -277,8 +280,7 @@ func TestServerRead(t *testing.T) { require.NoError(t, err) defer s.Close() - ur := "http://" - ur += "localhost:8886/teststream/whep?param=value" + ur := "http://myuser:mypass@localhost:8886/teststream/whep?param=value" u, err := url.Parse(ur) require.NoError(t, err) @@ -370,7 +372,8 @@ func TestServerReadNotFound(t *testing.T) { hc := &http.Client{Transport: &http.Transport{}} - iceServers, err := webrtc.WHIPOptionsICEServers(context.Background(), hc, "http://localhost:8886/nonexisting/whep") + iceServers, err := webrtc.WHIPOptionsICEServers(context.Background(), hc, + "http://myuser:mypass@localhost:8886/nonexisting/whep") require.NoError(t, err) pc, err := pwebrtc.NewPeerConnection(pwebrtc.Configuration{ @@ -386,7 +389,7 @@ func TestServerReadNotFound(t *testing.T) { require.NoError(t, err) req, err := http.NewRequest(http.MethodPost, - "http://localhost:8886/nonexisting/whep", bytes.NewReader([]byte(offer.SDP))) + "http://myuser:mypass@localhost:8886/nonexisting/whep", bytes.NewReader([]byte(offer.SDP))) require.NoError(t, err) req.Header.Set("Content-Type", "application/sdp") diff --git a/internal/servers/webrtc/session.go b/internal/servers/webrtc/session.go index 525235dcff53..67e3a2a8583c 100644 --- a/internal/servers/webrtc/session.go +++ b/internal/servers/webrtc/session.go @@ -22,6 +22,7 @@ import ( pwebrtc "github.com/pion/webrtc/v3" "github.com/bluenviron/mediamtx/internal/asyncwriter" + "github.com/bluenviron/mediamtx/internal/auth" "github.com/bluenviron/mediamtx/internal/defs" "github.com/bluenviron/mediamtx/internal/externalcmd" "github.com/bluenviron/mediamtx/internal/hooks" @@ -374,15 +375,15 @@ func (s *session) runPublish() (int, error) { IP: net.ParseIP(ip), User: s.req.user, Pass: s.req.pass, - Proto: defs.AuthProtocolWebRTC, + Proto: auth.ProtocolWebRTC, ID: &s.uuid, }, }) if err != nil { - var terr defs.AuthenticationError + var terr auth.Error if errors.As(err, &terr) { // wait some seconds to mitigate brute force attacks - <-time.After(pauseAfterAuthError) + <-time.After(auth.PauseAfterError) return http.StatusUnauthorized, err } @@ -505,15 +506,15 @@ func (s *session) runRead() (int, error) { IP: net.ParseIP(ip), User: s.req.user, Pass: s.req.pass, - Proto: defs.AuthProtocolWebRTC, + Proto: auth.ProtocolWebRTC, ID: &s.uuid, }, }) if err != nil { - var terr1 defs.AuthenticationError + var terr1 auth.Error if errors.As(err, &terr1) { // wait some seconds to mitigate brute force attacks - <-time.After(pauseAfterAuthError) + <-time.After(auth.PauseAfterError) return http.StatusUnauthorized, err } diff --git a/mediamtx.yml b/mediamtx.yml index c5738018152b..b4a8a7dc8ee4 100644 --- a/mediamtx.yml +++ b/mediamtx.yml @@ -24,32 +24,15 @@ writeQueueSize: 512 # This can be decreased to avoid fragmentation on networks with a low UDP MTU. udpMaxPayloadSize: 1472 -# HTTP URL to perform external authentication. -# Every time a user wants to authenticate, the server calls this URL -# with the POST method and a body containing: -# { -# "ip": "ip", -# "user": "user", -# "password": "password", -# "path": "path", -# "protocol": "rtsp|rtmp|hls|webrtc", -# "id": "id", -# "action": "read|publish", -# "query": "query" -# } -# If the response code is 20x, authentication is accepted, otherwise -# it is discarded. -externalAuthenticationURL: - # Enable Prometheus-compatible metrics. metrics: no # Address of the metrics listener. -metricsAddress: 127.0.0.1:9998 +metricsAddress: :9998 # Enable pprof-compatible endpoint to monitor performances. pprof: no # Address of the pprof listener. -pprofAddress: 127.0.0.1:9999 +pprofAddress: :9999 # Command to run when a client connects to the server. # This is terminated with SIGINT when a client disconnects from the server. @@ -64,13 +47,98 @@ runOnConnectRestart: no # Environment variables are the same of runOnConnect. runOnDisconnect: +############################################### +# Global settings -> Authentication + +# Authentication method. Available values are: +# * internal: users are stored in the configuration file +# * http: an external HTTP URL is contacted to perform authentication +# * jwt: an external identity server provides authentication through JWTs +authMethod: internal + +# Internal authentication. +# list of users. +authInternalUsers: + # Default unprivileged user. + # Username. 'any' means any user, including anonymous ones. +- user: any + # Password. Not used in case of 'any' user. + pass: + # IPs or networks allowed to use this user. An empty list means any IP. + ips: [] + # List of permissions. + permissions: + # Available actions are: publish, read, playback, api, metrics, pprof. + - action: publish + # Paths can be set to further restrict access to a specific path. + # An empty path means any path. + # Regular expressions can be used by using a tilde as prefix. + path: + - action: read + path: + - action: playback + path: + + # Default administrator. + # This allows to use API, metrics and PPROF without authentication, + # if the IP is localhost. +- user: any + pass: + ips: ['127.0.0.1', '::1'] + permissions: + - action: api + - action: metrics + - action: pprof + +# HTTP-based authentication. +# URL called to perform authentication. Every time a user wants +# to authenticate, the server calls this URL with the POST method +# and a body containing: +# { +# "user": "user", +# "password": "password", +# "ip": "ip", +# "action": "publish|read|playback|api|metrics|pprof", +# "path": "path", +# "protocol": "rtsp|rtmp|hls|webrtc|srt", +# "id": "id", +# "query": "query" +# } +# If the response code is 20x, authentication is accepted, otherwise +# it is discarded. +authHTTPAddress: +# Actions to exclude from HTTP-based authentication. +# Format is the same as the one of user permissions. +authHTTPExclude: +- action: api +- action: metrics +- action: pprof + +# JWT-based authentication. +# Users have to login through an external identity server and obtain a JWT. +# This JWT must contain the claim "mediamtx_permissions" with permissions, +# for instance: +# { +# ... +# "mediamtx_permissions": [ +# { +# "action": "publish", +# "path": "somepath" +# } +# ] +# } +# Users are then expected to pass the JWT as a query parameter, i.e. ?jwt=... +# This is the JWKS URL that will be used to pull (once) the public key that allows +# to validate JWTs. +authJWTJWKS: + ############################################### # Global settings -> API # Enable controlling the server through the API. api: no # Address of the API listener. -apiAddress: 127.0.0.1:9997 +apiAddress: :9997 ############################################### # Global settings -> Playback server @@ -117,7 +185,7 @@ serverKey: server.key # Path to the server certificate. This is needed only when encryption is "strict" or "optional". serverCert: server.crt # Authentication methods. Available are "basic" and "digest". -# "digest" doesn't provide any additional security and is available for compatibility reasons only. +# "digest" doesn't provide any additional security and is available for compatibility only. rtspAuthMethods: [basic] ############################################### @@ -327,27 +395,6 @@ pathDefaults: # Set to 0s to disable automatic deletion. recordDeleteAfter: 24h - ############################################### - # Default path settings -> Authentication - - # Username required to publish. - # Hashed values can be inserted with the "argon2:" or "sha256:" prefix. - publishUser: - # Password required to publish. - # Hashed values can be inserted with the "argon2:" or "sha256:" prefix. - publishPass: - # IPs or networks (x.x.x.x/24) allowed to publish. - publishIPs: [] - - # Username required to read. - # Hashed values can be inserted with the "argon2:" or "sha256:" prefix. - readUser: - # password required to read. - # Hashed values can be inserted with the "argon2:" or "sha256:" prefix. - readPass: - # IPs or networks (x.x.x.x/24) allowed to read. - readIPs: [] - ############################################### # Default path settings -> Publisher source (when source is "publisher")