Browse Source
This is a new authentication system that covers all the features exposed by the server, including playback, API, metrics and PPROF, improves internal authentication by adding permissions, improves HTTP-based authentication by adding the ability to exclude certain actions from being authenticated, adds an additional method (JWT-based authentication).pull/3096/head
53 changed files with 1976 additions and 947 deletions
@ -0,0 +1,327 @@
@@ -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 |
||||
} |
@ -0,0 +1,309 @@
@@ -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) |
||||
} |
@ -0,0 +1,52 @@
@@ -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 + `"`)) |
||||
} |
@ -0,0 +1,15 @@
@@ -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"` |
||||
} |
@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
|
||||
package conf |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"fmt" |
||||
"net" |
||||
"sort" |
||||
"strings" |
||||
) |
||||
|
||||
// IPNetworks is a parameter that contains a list of IP networks.
|
||||
type IPNetworks []net.IPNet |
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
func (d IPNetworks) MarshalJSON() ([]byte, error) { |
||||
out := make([]string, len(d)) |
||||
|
||||
for i, v := range d { |
||||
out[i] = v.String() |
||||
} |
||||
|
||||
sort.Strings(out) |
||||
|
||||
return json.Marshal(out) |
||||
} |
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler.
|
||||
func (d *IPNetworks) UnmarshalJSON(b []byte) error { |
||||
var in []string |
||||
if err := json.Unmarshal(b, &in); err != nil { |
||||
return err |
||||
} |
||||
|
||||
*d = nil |
||||
|
||||
if len(in) == 0 { |
||||
return nil |
||||
} |
||||
|
||||
for _, t := range in { |
||||
if _, ipnet, err := net.ParseCIDR(t); err == nil { |
||||
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 { |
||||
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) |
||||
} |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
// UnmarshalEnv implements env.Unmarshaler.
|
||||
func (d *IPNetworks) UnmarshalEnv(_ string, v string) error { |
||||
byts, _ := json.Marshal(strings.Split(v, ",")) |
||||
return d.UnmarshalJSON(byts) |
||||
} |
||||
|
||||
// ToTrustedProxies converts IPNetworks into a string slice for SetTrustedProxies.
|
||||
func (d *IPNetworks) ToTrustedProxies() []string { |
||||
ret := make([]string, len(*d)) |
||||
for i, entry := range *d { |
||||
ret[i] = entry.String() |
||||
} |
||||
return ret |
||||
} |
||||
|
||||
// Contains checks whether the IP is part of one of the networks.
|
||||
func (d IPNetworks) Contains(ip net.IP) bool { |
||||
for _, network := range d { |
||||
if network.Contains(ip) { |
||||
return true |
||||
} |
||||
} |
||||
return false |
||||
} |
@ -1,66 +0,0 @@
@@ -1,66 +0,0 @@
|
||||
package conf |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"fmt" |
||||
"net" |
||||
"sort" |
||||
"strings" |
||||
) |
||||
|
||||
// IPsOrCIDRs is a parameter that contains a list of IPs or CIDRs.
|
||||
type IPsOrCIDRs []fmt.Stringer |
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
func (d IPsOrCIDRs) MarshalJSON() ([]byte, error) { |
||||
out := make([]string, len(d)) |
||||
|
||||
for i, v := range d { |
||||
out[i] = v.String() |
||||
} |
||||
|
||||
sort.Strings(out) |
||||
|
||||
return json.Marshal(out) |
||||
} |
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler.
|
||||
func (d *IPsOrCIDRs) UnmarshalJSON(b []byte) error { |
||||
var in []string |
||||
if err := json.Unmarshal(b, &in); err != nil { |
||||
return err |
||||
} |
||||
|
||||
*d = nil |
||||
|
||||
if len(in) == 0 { |
||||
return nil |
||||
} |
||||
|
||||
for _, t := range in { |
||||
if _, ipnet, err := net.ParseCIDR(t); err == nil { |
||||
*d = append(*d, ipnet) |
||||
} else if ip := net.ParseIP(t); ip != nil { |
||||
*d = append(*d, ip) |
||||
} else { |
||||
return fmt.Errorf("unable to parse IP/CIDR '%s'", t) |
||||
} |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
// UnmarshalEnv implements env.Unmarshaler.
|
||||
func (d *IPsOrCIDRs) UnmarshalEnv(_ string, v string) error { |
||||
byts, _ := json.Marshal(strings.Split(v, ",")) |
||||
return d.UnmarshalJSON(byts) |
||||
} |
||||
|
||||
// ToTrustedProxies converts IPsOrCIDRs into a string slice for SetTrustedProxies.
|
||||
func (d *IPsOrCIDRs) ToTrustedProxies() []string { |
||||
ret := make([]string, len(*d)) |
||||
for i, entry := range *d { |
||||
ret[i] = entry.String() |
||||
} |
||||
return ret |
||||
} |
@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
|
||||
package conf |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"fmt" |
||||
"sort" |
||||
"strings" |
||||
|
||||
"github.com/bluenviron/gortsplib/v4/pkg/headers" |
||||
) |
||||
|
||||
// RTSPAuthMethods is the rtspAuthMethods parameter.
|
||||
type RTSPAuthMethods []headers.AuthMethod |
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
func (d RTSPAuthMethods) MarshalJSON() ([]byte, error) { |
||||
out := make([]string, len(d)) |
||||
|
||||
for i, v := range d { |
||||
switch v { |
||||
case headers.AuthBasic: |
||||
out[i] = "basic" |
||||
|
||||
default: |
||||
out[i] = "digest" |
||||
} |
||||
} |
||||
|
||||
sort.Strings(out) |
||||
|
||||
return json.Marshal(out) |
||||
} |
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler.
|
||||
func (d *RTSPAuthMethods) UnmarshalJSON(b []byte) error { |
||||
var in []string |
||||
if err := json.Unmarshal(b, &in); err != nil { |
||||
return err |
||||
} |
||||
|
||||
*d = nil |
||||
|
||||
for _, v := range in { |
||||
switch v { |
||||
case "basic": |
||||
*d = append(*d, headers.AuthBasic) |
||||
|
||||
case "digest": |
||||
*d = append(*d, headers.AuthDigestMD5) |
||||
|
||||
default: |
||||
return fmt.Errorf("invalid authentication method: '%s'", v) |
||||
} |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
// UnmarshalEnv implements env.Unmarshaler.
|
||||
func (d *RTSPAuthMethods) UnmarshalEnv(_ string, v string) error { |
||||
byts, _ := json.Marshal(strings.Split(v, ",")) |
||||
return d.UnmarshalJSON(byts) |
||||
} |
@ -1,126 +0,0 @@
@@ -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.AuthMethods, |
||||
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.IPsOrCIDRs |
||||
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 !ipEqualOrInRange(accessRequest.IP, pathIPs) { |
||||
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 |
||||
} |
@ -1,155 +0,0 @@
@@ -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.AuthMethods{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.AuthMethods{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.AuthMethods{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) |
||||
} |
@ -1,23 +0,0 @@
@@ -1,23 +0,0 @@
|
||||
package core |
||||
|
||||
import ( |
||||
"fmt" |
||||
"net" |
||||
) |
||||
|
||||
func ipEqualOrInRange(ip net.IP, ips []fmt.Stringer) bool { |
||||
for _, item := range ips { |
||||
switch titem := item.(type) { |
||||
case net.IP: |
||||
if titem.Equal(ip) { |
||||
return true |
||||
} |
||||
|
||||
case *net.IPNet: |
||||
if titem.Contains(ip) { |
||||
return true |
||||
} |
||||
} |
||||
} |
||||
return false |
||||
} |
@ -1,23 +0,0 @@
@@ -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 |
||||
} |
Loading…
Reference in new issue