diff --git a/README.md b/README.md index a8d0f138..6eadee04 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Features: * Pause without disconnecting from the server * Server * Handle requests from clients + * Validate client credentials * Read media streams from clients ("record") * Read streams with the UDP or TCP transport protocol * Read TLS-encrypted streams (TCP only) @@ -94,6 +95,7 @@ Features: * [client-record-format-vp9](examples/client-record-format-vp9/main.go) * [server](examples/server/main.go) * [server-tls](examples/server-tls/main.go) +* [server-auth](examples/server-auth/main.go) * [server-h264-save-to-disk](examples/server-h264-save-to-disk/main.go) * [proxy](examples/proxy/main.go) diff --git a/client.go b/client.go index 42e8f7e3..0ae76524 100644 --- a/client.go +++ b/client.go @@ -34,6 +34,10 @@ import ( "github.com/bluenviron/gortsplib/v4/pkg/sdp" ) +const ( + clientUserAgent = "gortsplib" +) + // avoid an int64 overflow and preserve resolution by splitting division into two parts: // first add the integer part, then the decimal part. func multiplyAndDivide(v, m, d time.Duration) time.Duration { @@ -386,7 +390,7 @@ func (c *Client) Start(scheme string, host string) error { return fmt.Errorf("MaxPacketSize must be less than %d", udpMaxPayloadSize) } if c.UserAgent == "" { - c.UserAgent = "gortsplib" + c.UserAgent = clientUserAgent } // system functions diff --git a/examples/server-auth/main.go b/examples/server-auth/main.go new file mode 100644 index 00000000..2fab626f --- /dev/null +++ b/examples/server-auth/main.go @@ -0,0 +1,195 @@ +package main + +import ( + "log" + "sync" + + "github.com/pion/rtp" + + "github.com/bluenviron/gortsplib/v4" + "github.com/bluenviron/gortsplib/v4/pkg/base" + "github.com/bluenviron/gortsplib/v4/pkg/description" + "github.com/bluenviron/gortsplib/v4/pkg/format" + "github.com/bluenviron/gortsplib/v4/pkg/liberrors" +) + +// This example shows how to +// 1. create a RTSP server which accepts plain connections +// 2. allow a single client to publish a stream with TCP or UDP, if it provides credentials +// 3. allow multiple clients to read that stream with TCP, UDP or UDP-multicast, if they provide credentials + +const ( + // credentials required to publish the stream + publishUser = "publishuser" + publishPass = "publishpass" + + // credentials required to read the stream + readUser = "readuser" + readPass = "readpass" +) + +type serverHandler struct { + s *gortsplib.Server + mutex sync.Mutex + stream *gortsplib.ServerStream + publisher *gortsplib.ServerSession +} + +// called when a connection is opened. +func (sh *serverHandler) OnConnOpen(ctx *gortsplib.ServerHandlerOnConnOpenCtx) { + log.Printf("conn opened") +} + +// called when a connection is closed. +func (sh *serverHandler) OnConnClose(ctx *gortsplib.ServerHandlerOnConnCloseCtx) { + log.Printf("conn closed (%v)", ctx.Error) +} + +// called when a session is opened. +func (sh *serverHandler) OnSessionOpen(ctx *gortsplib.ServerHandlerOnSessionOpenCtx) { + log.Printf("session opened") +} + +// called when a session is closed. +func (sh *serverHandler) OnSessionClose(ctx *gortsplib.ServerHandlerOnSessionCloseCtx) { + log.Printf("session closed") + + sh.mutex.Lock() + defer sh.mutex.Unlock() + + // if the session is the publisher, + // close the stream and disconnect any reader. + if sh.stream != nil && ctx.Session == sh.publisher { + sh.stream.Close() + sh.stream = nil + } +} + +// called when receiving a DESCRIBE request. +func (sh *serverHandler) OnDescribe(ctx *gortsplib.ServerHandlerOnDescribeCtx) (*base.Response, *gortsplib.ServerStream, error) { + log.Printf("describe request") + + // Verify reader credentials. + // In case of readers, credentials have to be verified during DESCRIBE and SETUP. + ok := ctx.Conn.VerifyCredentials(ctx.Request, readUser, readPass) + if !ok { + return &base.Response{ + StatusCode: base.StatusUnauthorized, + }, nil, liberrors.ErrServerAuth{} + } + + sh.mutex.Lock() + defer sh.mutex.Unlock() + + // no one is publishing yet + if sh.stream == nil { + return &base.Response{ + StatusCode: base.StatusNotFound, + }, nil, nil + } + + // send medias that are being published to the client + return &base.Response{ + StatusCode: base.StatusOK, + }, sh.stream, nil +} + +// called when receiving an ANNOUNCE request. +func (sh *serverHandler) OnAnnounce(ctx *gortsplib.ServerHandlerOnAnnounceCtx) (*base.Response, error) { + log.Printf("announce request") + + // Verify publisher credentials. + // In case of publishers, credentials have to be verified during ANNOUNCE. + ok := ctx.Conn.VerifyCredentials(ctx.Request, publishUser, publishPass) + if !ok { + return &base.Response{ + StatusCode: base.StatusUnauthorized, + }, liberrors.ErrServerAuth{} + } + + sh.mutex.Lock() + defer sh.mutex.Unlock() + + // disconnect existing publisher + if sh.stream != nil { + sh.stream.Close() + sh.publisher.Close() + } + + // create the stream and save the publisher + sh.stream = gortsplib.NewServerStream(sh.s, ctx.Description) + sh.publisher = ctx.Session + + return &base.Response{ + StatusCode: base.StatusOK, + }, nil +} + +// called when receiving a SETUP request. +func (sh *serverHandler) OnSetup(ctx *gortsplib.ServerHandlerOnSetupCtx) (*base.Response, *gortsplib.ServerStream, error) { + log.Printf("setup request") + + // Verify reader credentials. + // In case of readers, credentials have to be verified during DESCRIBE and SETUP. + if ctx.Session.State() == gortsplib.ServerSessionStateInitial { + ok := ctx.Conn.VerifyCredentials(ctx.Request, readUser, readPass) + if !ok { + return &base.Response{ + StatusCode: base.StatusUnauthorized, + }, nil, liberrors.ErrServerAuth{} + } + } + + // no one is publishing yet + if sh.stream == nil { + return &base.Response{ + StatusCode: base.StatusNotFound, + }, nil, nil + } + + return &base.Response{ + StatusCode: base.StatusOK, + }, sh.stream, nil +} + +// called when receiving a PLAY request. +func (sh *serverHandler) OnPlay(ctx *gortsplib.ServerHandlerOnPlayCtx) (*base.Response, error) { + log.Printf("play request") + + return &base.Response{ + StatusCode: base.StatusOK, + }, nil +} + +// called when receiving a RECORD request. +func (sh *serverHandler) OnRecord(ctx *gortsplib.ServerHandlerOnRecordCtx) (*base.Response, error) { + log.Printf("record request") + + // called when receiving a RTP packet + ctx.Session.OnPacketRTPAny(func(medi *description.Media, forma format.Format, pkt *rtp.Packet) { + // route the RTP packet to all readers + sh.stream.WritePacketRTP(medi, pkt) + }) + + return &base.Response{ + StatusCode: base.StatusOK, + }, nil +} + +func main() { + // configure the server + h := &serverHandler{} + h.s = &gortsplib.Server{ + Handler: h, + RTSPAddress: ":8554", + UDPRTPAddress: ":8000", + UDPRTCPAddress: ":8001", + MulticastIPRange: "224.1.0.0/16", + MulticastRTPPort: 8002, + MulticastRTCPPort: 8003, + } + + // start server and wait until a fatal error + log.Printf("server is ready") + panic(h.s.StartAndWait()) +} diff --git a/pkg/auth/sender.go b/pkg/auth/sender.go index de88227a..6c337280 100644 --- a/pkg/auth/sender.go +++ b/pkg/auth/sender.go @@ -53,11 +53,11 @@ func (se *Sender) AddAuthorization(req *base.Request) { Method: se.authHeader.Method, } + h.Username = se.user + if se.authHeader.Method == headers.AuthMethodBasic { - h.BasicUser = se.user h.BasicPass = se.pass } else { // digest - h.Username = se.user h.Realm = se.authHeader.Realm h.Nonce = se.authHeader.Nonce h.URI = urStr diff --git a/pkg/auth/verify.go b/pkg/auth/verify.go index e91c9457..af298e6f 100644 --- a/pkg/auth/verify.go +++ b/pkg/auth/verify.go @@ -61,7 +61,7 @@ const ( VerifyMethodDigestSHA256 ) -// Verify validates a request sent by a client. +// Verify verifies a request sent by a client. func Verify( req *base.Request, user string, @@ -119,7 +119,7 @@ func Verify( } case auth.Method == headers.AuthMethodBasic && contains(methods, VerifyMethodBasic): - if auth.BasicUser != user { + if auth.Username != user { return fmt.Errorf("authentication failed") } diff --git a/pkg/headers/authorization.go b/pkg/headers/authorization.go index e20dd97e..866fdd39 100644 --- a/pkg/headers/authorization.go +++ b/pkg/headers/authorization.go @@ -13,11 +13,16 @@ type Authorization struct { // authentication method Method AuthMethod + // username + Username string + // // Basic authentication fields // // user + // + // Deprecated: replaced by Username. BasicUser string // password @@ -27,9 +32,6 @@ type Authorization struct { // Digest authentication fields // - // username - Username string - // realm Realm string @@ -89,7 +91,8 @@ func (h *Authorization) Unmarshal(v base.HeaderValue) error { return fmt.Errorf("invalid value") } - h.BasicUser, h.BasicPass = tmp2[0], tmp2[1] + h.Username, h.BasicPass = tmp2[0], tmp2[1] + h.BasicUser = h.Username } else { // digest kvs, err := keyValParse(v0, ',') if err != nil { @@ -149,8 +152,11 @@ func (h *Authorization) Unmarshal(v base.HeaderValue) error { // Marshal encodes an Authorization header. func (h Authorization) Marshal() base.HeaderValue { if h.Method == AuthMethodBasic { + if h.BasicUser != "" { + h.Username = h.BasicUser + } return base.HeaderValue{"Basic " + - base64.StdEncoding.EncodeToString([]byte(h.BasicUser+":"+h.BasicPass))} + base64.StdEncoding.EncodeToString([]byte(h.Username+":"+h.BasicPass))} } ret := "Digest " + diff --git a/pkg/headers/authorization_test.go b/pkg/headers/authorization_test.go index c4d6ec24..31a5b189 100644 --- a/pkg/headers/authorization_test.go +++ b/pkg/headers/authorization_test.go @@ -24,6 +24,7 @@ var casesAuthorization = []struct { base.HeaderValue{"Basic bXl1c2VyOm15cGFzcw=="}, Authorization{ Method: AuthMethodBasic, + Username: "myuser", BasicUser: "myuser", BasicPass: "mypass", }, diff --git a/pkg/liberrors/server.go b/pkg/liberrors/server.go index eb358851..610faa63 100644 --- a/pkg/liberrors/server.go +++ b/pkg/liberrors/server.go @@ -270,3 +270,13 @@ func (ErrServerInvalidSetupPath) Error() string { "This typically happens when VLC fails a request, and then switches to an " + "unsupported RTSP dialect" } + +// ErrServerAuth is an error that can be returned by a server. +// If a client did not provide credentials, it will be asked for +// credentials instead of being kicked out. +type ErrServerAuth struct{} + +// Error implements the error interface. +func (e ErrServerAuth) Error() string { + return "authentication error" +} diff --git a/server.go b/server.go index cf4885d4..5c28afa8 100644 --- a/server.go +++ b/server.go @@ -9,10 +9,16 @@ import ( "sync" "time" + "github.com/bluenviron/gortsplib/v4/pkg/auth" "github.com/bluenviron/gortsplib/v4/pkg/base" "github.com/bluenviron/gortsplib/v4/pkg/liberrors" ) +const ( + serverHeader = "gortsplib" + serverAuthRealm = "ipcam" +) + func extractPort(address string) (int, error) { _, tmp, err := net.SplitHostPort(address) if err != nil { @@ -88,6 +94,9 @@ type Server struct { MaxPacketSize int // disable automatic RTCP sender reports. DisableRTCPSenderReports bool + // authentication methods. + // It defaults to plain and digest+MD5. + AuthMethods []auth.VerifyMethod // // handler (optional) @@ -156,6 +165,11 @@ func (s *Server) Start() error { } else if s.MaxPacketSize > udpMaxPayloadSize { return fmt.Errorf("MaxPacketSize must be less than %d", udpMaxPayloadSize) } + if len(s.AuthMethods) == 0 { + // disable VerifyMethodDigestSHA256 unless explicitly set + // since it prevents FFmpeg from authenticating + s.AuthMethods = []auth.VerifyMethod{auth.VerifyMethodBasic, auth.VerifyMethodDigestMD5} + } // system functions if s.Listen == nil { diff --git a/server_conn.go b/server_conn.go index 47d06f23..ba2bf7a6 100644 --- a/server_conn.go +++ b/server_conn.go @@ -10,10 +10,12 @@ import ( "strings" "time" + "github.com/bluenviron/gortsplib/v4/pkg/auth" "github.com/bluenviron/gortsplib/v4/pkg/base" "github.com/bluenviron/gortsplib/v4/pkg/bytecounter" "github.com/bluenviron/gortsplib/v4/pkg/conn" "github.com/bluenviron/gortsplib/v4/pkg/description" + "github.com/bluenviron/gortsplib/v4/pkg/headers" "github.com/bluenviron/gortsplib/v4/pkg/liberrors" ) @@ -46,6 +48,12 @@ func serverSideDescription(d *description.Session) *description.Session { return out } +func credentialsProvided(req *base.Request) bool { + var auth headers.Authorization + err := auth.Unmarshal(req.Header["Authorization"]) + return err == nil && auth.Username != "" +} + type readReq struct { req *base.Request res chan error @@ -64,6 +72,7 @@ type ServerConn struct { conn *conn.Conn session *ServerSession reader *serverConnReader + authNonce string // in chRemoveSession chan *ServerSession @@ -137,6 +146,48 @@ func (sc *ServerConn) Stats() *StatsConn { } } +// VerifyCredentials verifies credentials provided by the user. +func (sc *ServerConn) VerifyCredentials( + req *base.Request, + expectedUser string, + expectedPass string, +) bool { + // we do not support using an empty string as user + // since it interferes with credentialsProvided() + if expectedUser == "" { + return false + } + + if sc.authNonce == "" { + n, err := auth.GenerateNonce() + if err != nil { + return false + } + sc.authNonce = n + } + + err := auth.Verify( + req, + expectedUser, + expectedPass, + sc.s.AuthMethods, + serverAuthRealm, + sc.authNonce) + + return (err == nil) +} + +func (sc *ServerConn) handleAuthError(req *base.Request, res *base.Response) error { + // if credentials have not been provided, clear error and send the WWW-Authenticate header. + if !credentialsProvided(req) { + res.Header["WWW-Authenticate"] = auth.GenerateWWWAuthenticate(sc.s.AuthMethods, serverAuthRealm, sc.authNonce) + return nil + } + + // if credentials have been provided (and are wrong), close the connection. + return liberrors.ErrServerAuth{} +} + func (sc *ServerConn) ip() net.IP { return sc.remoteAddr.IP } @@ -386,14 +437,20 @@ func (sc *ServerConn) handleRequestOuter(req *base.Request) error { res.Header = make(base.Header) } + // handle auth errors + var eerr1 liberrors.ErrServerAuth + if errors.As(err, &eerr1) { + err = sc.handleAuthError(req, res) + } + // add cseq - var eerr liberrors.ErrServerCSeqMissing - if !errors.As(err, &eerr) { + var eerr2 liberrors.ErrServerCSeqMissing + if !errors.As(err, &eerr2) { res.Header["CSeq"] = req.Header["CSeq"] } // add server - res.Header["Server"] = base.HeaderValue{"gortsplib"} + res.Header["Server"] = base.HeaderValue{serverHeader} if h, ok := sc.s.Handler.(ServerHandlerOnResponse); ok { h.OnResponse(sc, res) diff --git a/server_test.go b/server_test.go index 0b664f4f..44d87735 100644 --- a/server_test.go +++ b/server_test.go @@ -3,6 +3,7 @@ package gortsplib import ( "fmt" "net" + "net/http" "testing" "time" @@ -13,6 +14,7 @@ import ( "github.com/bluenviron/gortsplib/v4/pkg/conn" "github.com/bluenviron/gortsplib/v4/pkg/description" "github.com/bluenviron/gortsplib/v4/pkg/headers" + "github.com/bluenviron/gortsplib/v4/pkg/liberrors" ) var serverCert = []byte(`-----BEGIN CERTIFICATE----- @@ -1035,20 +1037,87 @@ func TestServerSessionTeardown(t *testing.T) { } func TestServerAuth(t *testing.T) { - nonce, err := auth.GenerateNonce() - require.NoError(t, err) + for _, method := range []string{"all", "basic", "digest_md5", "digest_sha256"} { + t.Run(method, func(t *testing.T) { + s := &Server{ + Handler: &testServerHandler{ + onAnnounce: func(ctx *ServerHandlerOnAnnounceCtx) (*base.Response, error) { + ok := ctx.Conn.VerifyCredentials(ctx.Request, "myuser", "mypass") + if !ok { + return &base.Response{ + StatusCode: base.StatusUnauthorized, + }, liberrors.ErrServerAuth{} + } + return &base.Response{ + StatusCode: base.StatusOK, + }, nil + }, + }, + RTSPAddress: "localhost:8554", + AuthMethods: func() []auth.VerifyMethod { + switch method { + case "basic": + return []auth.VerifyMethod{auth.VerifyMethodBasic} + + case "digest_md5": + return []auth.VerifyMethod{auth.VerifyMethodDigestMD5} + + case "digest_sha256": + return []auth.VerifyMethod{auth.VerifyMethodDigestSHA256} + } + return nil + }(), + } + + err := s.Start() + require.NoError(t, err) + defer s.Close() + + nconn, err := net.Dial("tcp", "localhost:8554") + require.NoError(t, err) + defer nconn.Close() + conn := conn.NewConn(nconn) + + medias := []*description.Media{testH264Media} + + req := base.Request{ + Method: base.Announce, + URL: mustParseURL("rtsp://localhost:8554/teststream"), + Header: base.Header{ + "CSeq": base.HeaderValue{"1"}, + "Content-Type": base.HeaderValue{"application/sdp"}, + }, + Body: mediasToSDP(medias), + } + + res, err := writeReqReadRes(conn, req) + require.NoError(t, err) + require.Equal(t, base.StatusUnauthorized, res.StatusCode) + + sender, err := auth.NewSender(res.Header["WWW-Authenticate"], "myuser", "mypass") + require.NoError(t, err) + + sender.AddAuthorization(&req) + res, err = writeReqReadRes(conn, req) + require.NoError(t, err) + require.Equal(t, base.StatusOK, res.StatusCode) + }) + } +} + +func TestServerAuthFail(t *testing.T) { s := &Server{ Handler: &testServerHandler{ + onConnClose: func(ctx *ServerHandlerOnConnCloseCtx) { + require.EqualError(t, ctx.Error, "authentication error") + }, onAnnounce: func(ctx *ServerHandlerOnAnnounceCtx) (*base.Response, error) { - err2 := auth.Verify(ctx.Request, "myuser", "mypass", nil, "IPCAM", nonce) - if err2 != nil { - return &base.Response{ //nolint:nilerr - StatusCode: base.StatusUnauthorized, - Header: base.Header{ - "WWW-Authenticate": auth.GenerateWWWAuthenticate(nil, "IPCAM", nonce), - }, - }, nil + ok := ctx.Conn.VerifyCredentials(ctx.Request, "myuser2", "mypass2") + if !ok { + return &base.Response{ + StatusCode: http.StatusUnauthorized, + }, liberrors.ErrServerAuth{} } return &base.Response{ @@ -1059,7 +1128,7 @@ func TestServerAuth(t *testing.T) { RTSPAddress: "localhost:8554", } - err = s.Start() + err := s.Start() require.NoError(t, err) defer s.Close() @@ -1088,7 +1157,11 @@ func TestServerAuth(t *testing.T) { require.NoError(t, err) sender.AddAuthorization(&req) + res, err = writeReqReadRes(conn, req) require.NoError(t, err) - require.Equal(t, base.StatusOK, res.StatusCode) + require.Equal(t, base.StatusUnauthorized, res.StatusCode) + + _, err = writeReqReadRes(conn, req) + require.Error(t, err) }