326 lines
7.1 KiB
Go
326 lines
7.1 KiB
Go
// 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
|
|
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),
|
|
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 cc customClaims
|
|
_, err = jwt.ParseWithClaims(v["jwt"][0], &cc, keyfunc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !matchesPermission(cc.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
|
|
}
|