Skip to content

Commit

Permalink
support using JWT in Authorization header with API, Metrics, PProf (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
aler9 committed Oct 5, 2024
1 parent 4b9d3ce commit 075d4de
Show file tree
Hide file tree
Showing 24 changed files with 268 additions and 508 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1188,7 +1188,7 @@ The JWT is expected to contain a claim, with a list of permissions in the same f
}
```
Clients are expected to pass the JWT in the Authorization header (in case of HLS and WebRTC) or in query parameters (in case of all other protocols), for instance:
Clients are expected to pass the JWT in the Authorization header (in case of HLS, WebRTC and all web-based features) or in query parameters (in case of all other protocols), for instance:
```
ffmpeg -re -stream_loop -1 -i file.ts -c copy -f rtsp rtsp://localhost:8554/mystream?jwt=MY_JWT
Expand Down
12 changes: 4 additions & 8 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,17 +284,13 @@ func (a *API) middlewareOrigin(ctx *gin.Context) {
}

func (a *API) middlewareAuth(ctx *gin.Context) {
user, pass, hasCredentials := ctx.Request.BasicAuth()

err := a.AuthManager.Authenticate(&auth.Request{
User: user,
Pass: pass,
Query: ctx.Request.URL.RawQuery,
IP: net.ParseIP(ctx.ClientIP()),
Action: conf.AuthActionAPI,
IP: net.ParseIP(ctx.ClientIP()),
Action: conf.AuthActionAPI,
HTTPRequest: ctx.Request,
})
if err != nil {
if !hasCredentials {
if err.(*auth.Error).AskCredentials { //nolint:errorlint

Check warning on line 293 in internal/api/api.go

View check run for this annotation

Codecov / codecov/patch

internal/api/api.go#L293

Added line #L293 was not covered by tests
ctx.Header("WWW-Authenticate", `Basic realm="mediamtx"`)
ctx.AbortWithStatus(http.StatusUnauthorized)
return
Expand Down
35 changes: 0 additions & 35 deletions internal/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ 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"
Expand Down Expand Up @@ -111,40 +110,6 @@ func TestPreflightRequest(t *testing.T) {
require.Equal(t, byts, []byte{})
}

func TestConfigAuth(t *testing.T) {
cnf := tempConf(t, "api: yes\n")

api := API{
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: &test.AuthManager{
Func: func(req *auth.Request) error {
require.Equal(t, &auth.Request{
User: "myuser",
Pass: "mypass",
IP: req.IP,
Action: "api",
Query: "key=val",
}, req)
return nil
},
},
Parent: &testParent{},
}
err := api.Initialize()
require.NoError(t, err)
defer api.Close()

tr := &http.Transport{}
defer tr.CloseIdleConnections()
hc := &http.Client{Transport: tr}

var out map[string]interface{}
httpRequest(t, hc, http.MethodGet, "http://myuser:mypass@localhost:9997/v3/config/global/get?key=val", nil, &out)
require.Equal(t, true, out["api"])
}

func TestConfigGlobalGet(t *testing.T) {
cnf := tempConf(t, "api: yes\n")

Expand Down
68 changes: 50 additions & 18 deletions internal/auth/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ const (
jwtRefreshPeriod = 60 * 60 * time.Second
)

func addJWTFromAuthorization(rawQuery string, auth string) string {
jwt := strings.TrimPrefix(auth, "Bearer ")
if rawQuery != "" {
if v, err := url.ParseQuery(rawQuery); err == nil && v.Get("jwt") == "" {
v.Set("jwt", jwt)
return v.Encode()
}

Check warning on line 40 in internal/auth/manager.go

View check run for this annotation

Codecov / codecov/patch

internal/auth/manager.go#L37-L40

Added lines #L37 - L40 were not covered by tests
}
return url.Values{"jwt": []string{jwt}}.Encode()
}

// Protocol is a protocol.
type Protocol string

Expand All @@ -51,21 +62,27 @@ type Request struct {
Action conf.AuthAction

// only for ActionPublish, ActionRead, ActionPlayback
Path string
Protocol Protocol
ID *uuid.UUID
Query string
Path string
Protocol Protocol
ID *uuid.UUID
Query string

// RTSP only
RTSPRequest *base.Request
RTSPNonce string

// HTTP only
HTTPRequest *http.Request
}

// Error is a authentication error.
type Error struct {
Message string
Message string
AskCredentials bool
}

// Error implements the error interface.
func (e Error) Error() string {
func (e *Error) Error() string {

Check warning on line 85 in internal/auth/manager.go

View check run for this annotation

Codecov / codecov/patch

internal/auth/manager.go#L85

Added line #L85 was not covered by tests
return "authentication failed: " + e.Message
}

Expand Down Expand Up @@ -154,15 +171,6 @@ func (m *Manager) ReloadInternalUsers(u []conf.AuthInternalUser) {

// 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 {
Expand All @@ -175,18 +183,42 @@ func (m *Manager) authenticateInner(req *Request) error {
req.User = rtspAuthHeader.Username
}
}
} else if req.HTTPRequest != nil {
req.User, req.Pass, _ = req.HTTPRequest.BasicAuth()
req.Query = req.HTTPRequest.URL.RawQuery

if h := req.HTTPRequest.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
// support passing username and password through Authorization header
if parts := strings.Split(strings.TrimPrefix(h, "Bearer "), ":"); len(parts) == 2 {
req.User = parts[0]
req.Pass = parts[1]
} else {
req.Query = addJWTFromAuthorization(req.Query, h)
}
}
}

var err error

switch m.Method {
case conf.AuthMethodInternal:
return m.authenticateInternal(req, &rtspAuthHeader)
err = m.authenticateInternal(req, &rtspAuthHeader)

case conf.AuthMethodHTTP:
return m.authenticateHTTP(req)
err = m.authenticateHTTP(req)

default:
return m.authenticateJWT(req)
err = m.authenticateJWT(req)
}

if err != nil {
return &Error{
Message: err.Error(),
AskCredentials: (req.User == "" && req.Pass == ""),
}
}

return nil
}

func (m *Manager) authenticateInternal(req *Request, rtspAuthHeader *headers.Authorization) error {
Expand Down
171 changes: 109 additions & 62 deletions internal/auth/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"encoding/json"
"net"
"net/http"
"net/url"
"testing"
"time"

Expand Down Expand Up @@ -186,6 +187,37 @@ func TestAuthInternalRTSPDigest(t *testing.T) {
require.NoError(t, err)
}

func TestAuthInternalCredentialsInBearer(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: []auth.ValidateMethod{auth.ValidateMethodDigestMD5},
}

err := m.Authenticate(&Request{
IP: net.ParseIP("127.1.1.1"),
Action: conf.AuthActionPublish,
Path: "mypath",
Protocol: ProtocolRTSP,
HTTPRequest: &http.Request{
Header: http.Header{"Authorization": []string{"Bearer myuser:mypass"}},
URL: &url.URL{},
},
})
require.NoError(t, err)
}

func TestAuthHTTP(t *testing.T) {
for _, outcome := range []string{"ok", "fail"} {
t.Run(outcome, func(t *testing.T) {
Expand Down Expand Up @@ -292,78 +324,93 @@ func TestAuthJWT(t *testing.T) {
// taken from
// https://github.com/MicahParks/jwkset/blob/master/examples/http_server/main.go

key, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err)
for _, ca := range []string{"query", "auth header"} {
t.Run(ca, func(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, err)

jwk, err := jwkset.NewJWKFromKey(key, jwkset.JWKOptions{
Metadata: jwkset.JWKMetadataOptions{
KID: "test-key-id",
},
})
require.NoError(t, err)
jwk, err := jwkset.NewJWKFromKey(key, jwkset.JWKOptions{
Metadata: jwkset.JWKMetadataOptions{
KID: "test-key-id",
},
})
require.NoError(t, err)

jwkSet := jwkset.NewMemoryStorage()
err = jwkSet.KeyWrite(context.Background(), jwk)
require.NoError(t, err)
jwkSet := jwkset.NewMemoryStorage()
err = jwkSet.KeyWrite(context.Background(), jwk)
require.NoError(t, err)

httpServ := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response, err2 := jwkSet.JSONPublic(r.Context())
if err2 != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}

httpServ := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response, err2 := jwkSet.JSONPublic(r.Context())
if err2 != nil {
w.WriteHeader(http.StatusInternalServerError)
return
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(response)
}),
}

w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(response)
}),
}
ln, err := net.Listen("tcp", "localhost:4567")
require.NoError(t, err)

ln, err := net.Listen("tcp", "localhost:4567")
require.NoError(t, err)
go httpServ.Serve(ln)
defer httpServ.Shutdown(context.Background())

go httpServ.Serve(ln)
defer httpServ.Shutdown(context.Background())
type customClaims struct {
jwt.RegisteredClaims
MediaMTXPermissions []conf.AuthInternalUserPermission `json:"my_permission_key"`
}

type customClaims struct {
jwt.RegisteredClaims
MediaMTXPermissions []conf.AuthInternalUserPermission `json:"my_permission_key"`
}
claims := customClaims{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "test",
Subject: "somebody",
ID: "1",
},
MediaMTXPermissions: []conf.AuthInternalUserPermission{{
Action: conf.AuthActionPublish,
Path: "mypath",
}},
}

claims := customClaims{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "test",
Subject: "somebody",
ID: "1",
},
MediaMTXPermissions: []conf.AuthInternalUserPermission{{
Action: conf.AuthActionPublish,
Path: "mypath",
}},
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header[jwkset.HeaderKID] = "test-key-id"
ss, err := token.SignedString(key)
require.NoError(t, err)

token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header[jwkset.HeaderKID] = "test-key-id"
ss, err := token.SignedString(key)
require.NoError(t, err)
m := Manager{
Method: conf.AuthMethodJWT,
JWTJWKS: "http://localhost:4567/jwks",
JWTClaimKey: "my_permission_key",
}

m := Manager{
Method: conf.AuthMethodJWT,
JWTJWKS: "http://localhost:4567/jwks",
JWTClaimKey: "my_permission_key",
if ca == "query" {
err = m.Authenticate(&Request{
IP: net.ParseIP("127.0.0.1"),
Action: conf.AuthActionPublish,
Path: "mypath",
Protocol: ProtocolRTSP,
Query: "param=value&jwt=" + ss,
})
} else {
err = m.Authenticate(&Request{
IP: net.ParseIP("127.0.0.1"),
Action: conf.AuthActionPublish,
Path: "mypath",
Protocol: ProtocolWebRTC,
HTTPRequest: &http.Request{
Header: http.Header{"Authorization": []string{"Bearer " + ss}},
URL: &url.URL{},
},
})
}
require.NoError(t, err)
})
}

err = m.Authenticate(&Request{
User: "",
Pass: "",
IP: net.ParseIP("127.0.0.1"),
Action: conf.AuthActionPublish,
Path: "mypath",
Protocol: ProtocolRTSP,
Query: "param=value&jwt=" + ss,
})
require.NoError(t, err)
}
Loading

0 comments on commit 075d4de

Please sign in to comment.