From d481bc3d340e21634a8c72493ddcd3a5c9717764 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Thu, 11 Jan 2024 12:08:39 +0100 Subject: [PATCH] WebDAV: Add token authentication tests #808 #3943 Signed-off-by: Michael Mayer --- internal/entity/auth_session.go | 4 ++ internal/server/webdav_auth.go | 52 ++++----------- internal/server/webdav_auth_session.go | 49 ++++++++++++++ internal/server/webdav_auth_test.go | 90 ++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 38 deletions(-) create mode 100644 internal/server/webdav_auth_session.go diff --git a/internal/entity/auth_session.go b/internal/entity/auth_session.go index 8d626fe4d..a8db71d0f 100644 --- a/internal/entity/auth_session.go +++ b/internal/entity/auth_session.go @@ -564,6 +564,10 @@ func (m *Session) NoUser() bool { // HasUser checks if a user account is assigned to the session. func (m *Session) HasUser() bool { + if m == nil { + return false + } + return m.UserUID != "" } diff --git a/internal/server/webdav_auth.go b/internal/server/webdav_auth.go index 3f73a37ce..6074a7e5b 100644 --- a/internal/server/webdav_auth.go +++ b/internal/server/webdav_auth.go @@ -85,7 +85,7 @@ func WebDAVAuth(conf *config.Config) gin.HandlerFunc { } // Allow webdav access based on the auth token or secret provided? - if sess, user, sid, cached := WebDAVSession(c, authToken); cached && user != nil { + if sess, user, sid, cached := WebDAVAuthSession(c, authToken); cached && user != nil { // Add user to request context and return to signal successful authentication. c.Set(gin.AuthUserKey, user) return @@ -94,17 +94,25 @@ func WebDAVAuth(conf *config.Config) gin.HandlerFunc { } else if !sess.HasUser() || user == nil { // Log error if session does not belong to an authorized user account. event.AuditErr([]string{clientIp, "session %s", "access webdav without authorized user account", "denied"}, sess.RefID) + WebDAVAbortUnauthorized(c) + return } else if sess.IsClient() && !sess.HasScope(acl.ResourceWebDAV.String()) { // Log error if the client is allowed to access webdav based on its scope. event.AuditErr([]string{clientIp, "client %s", "session %s", "access webdav without scope authorization", "denied"}, clean.Log(sess.AuthID), sess.RefID) + WebDAVAbortUnauthorized(c) + return } else if !user.CanUseWebDAV() { // Log warning if WebDAV is disabled for this account. message := "webdav access disabled" event.AuditWarn([]string{clientIp, "access webdav as %s", message}, clean.LogQuote(username)) + WebDAVAbortUnauthorized(c) + return } else if err := os.MkdirAll(filepath.Join(conf.OriginalsPath(), user.GetUploadPath()), fs.ModeDir); err != nil { // Log warning if upload path could not be created. message := "failed to create user upload path" event.AuditWarn([]string{clientIp, "access webdav as %s", message}, clean.LogQuote(username)) + WebDAVAbortServerError(c) + return } else { // Cache authentication to improve performance. webdavAuthCache.SetDefault(sid, user) @@ -159,6 +167,8 @@ func WebDAVAuth(conf *config.Config) gin.HandlerFunc { message := "failed to create user upload path" event.AuditWarn([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(username)) event.LoginError(clientIp, "webdav", username, api.UserAgent(c), message) + WebDAVAbortServerError(c) + return } else { // Log successful authentication. event.AuditInfo([]string{clientIp, "webdav login as %s", "succeeded"}, clean.LogQuote(username)) @@ -183,41 +193,7 @@ func WebDAVAbortUnauthorized(c *gin.Context) { c.AbortWithStatus(http.StatusUnauthorized) } -// WebDAVSession returns the client session that belongs to the auth token provided, or returns nil if it was not found. -func WebDAVSession(c *gin.Context, authToken string) (sess *entity.Session, user *entity.User, sid string, cached bool) { - if authToken == "" { - // Abort authentication if no token was provided. - return nil, nil, "", false - } else if !rnd.IsAuthToken(authToken) && !rnd.IsAuthSecret(authToken) { - // Abort authentication if token doesn't match expected format. - return nil, nil, "", false - } - - // Get session ID for the auth token provided. - sid = rnd.SessionID(authToken) - - // Check if client authorization has been cached to improve performance. - if cacheData, found := webdavAuthCache.Get(sid); found && cacheData != nil { - // Add cached user information to the request context. - user = cacheData.(*entity.User) - return nil, user, sid, true - } - - var err error - - // Find the session based on the hashed token used as session ID and return it. - sess, err = entity.FindSession(sid) - - // Log error and return nil if no matching session was found. - if sess == nil || err != nil { - event.AuditErr([]string{header.ClientIP(c), "access webdav", "invalid auth token or secret"}) - return nil, nil, sid, false - } - - // Update the client IP and the user agent from - // the request context if they have changed. - sess.UpdateContext(c) - - // Returns session and user if all checks have passed. - return sess, sess.User(), sid, false +// WebDAVAbortServerError aborts the request with the status internal server error. +func WebDAVAbortServerError(c *gin.Context) { + c.AbortWithStatus(http.StatusInternalServerError) } diff --git a/internal/server/webdav_auth_session.go b/internal/server/webdav_auth_session.go new file mode 100644 index 000000000..b813e3aa5 --- /dev/null +++ b/internal/server/webdav_auth_session.go @@ -0,0 +1,49 @@ +package server + +import ( + "github.com/gin-gonic/gin" + + "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/internal/event" + "github.com/photoprism/photoprism/pkg/header" + "github.com/photoprism/photoprism/pkg/rnd" +) + +// WebDAVAuthSession returns the client session that belongs to the auth token provided, or returns nil if it was not found. +func WebDAVAuthSession(c *gin.Context, authToken string) (sess *entity.Session, user *entity.User, sid string, cached bool) { + if authToken == "" { + // Abort authentication if no token was provided. + return nil, nil, "", false + } else if !rnd.IsAuthToken(authToken) && !rnd.IsAuthSecret(authToken) { + // Abort authentication if token doesn't match expected format. + return nil, nil, "", false + } + + // Get session ID for the auth token provided. + sid = rnd.SessionID(authToken) + + // Check if client authorization has been cached to improve performance. + if cacheData, found := webdavAuthCache.Get(sid); found && cacheData != nil { + // Add cached user information to the request context. + user = cacheData.(*entity.User) + return nil, user, sid, true + } + + var err error + + // Find the session based on the hashed token used as session ID and return it. + sess, err = entity.FindSession(sid) + + // Log error and return nil if no matching session was found. + if sess == nil || err != nil { + event.AuditErr([]string{header.ClientIP(c), "access webdav", "invalid auth token or secret"}) + return nil, nil, sid, false + } + + // Update the client IP and the user agent from + // the request context if they have changed. + sess.UpdateContext(c) + + // Returns session and user if all checks have passed. + return sess, sess.User(), sid, false +} diff --git a/internal/server/webdav_auth_test.go b/internal/server/webdav_auth_test.go index aa52c100f..3ba933b5a 100644 --- a/internal/server/webdav_auth_test.go +++ b/internal/server/webdav_auth_test.go @@ -10,6 +10,7 @@ import ( "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" + "github.com/photoprism/photoprism/internal/acl" "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/pkg/header" @@ -123,3 +124,92 @@ func TestWebDAVAuth(t *testing.T) { assert.Equal(t, BasicAuthRealm, c.Writer.Header().Get("WWW-Authenticate")) }) } + +func TestWebDAVAuthSession(t *testing.T) { + t.Run("AliceTokenWebdav", func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = &http.Request{ + Header: make(http.Header), + } + + s := entity.SessionFixtures.Get("alice_token_webdav") + + // Get session with authorized user and webdav scope. + sess, user, sid, cached := WebDAVAuthSession(c, s.AuthToken()) + + // Check result. + if cached { + assert.Nil(t, sess) + assert.NotNil(t, user) + assert.True(t, cached) + } else { + assert.NotNil(t, sess) + assert.NotNil(t, user) + assert.True(t, sess.HasUser()) + assert.Equal(t, user.UserUID, sess.UserUID) + assert.Equal(t, entity.UserFixtures.Get("alice").UserUID, sess.UserUID) + assert.True(t, sess.HasScope(acl.ResourceWebDAV.String())) + assert.False(t, cached) + } + + assert.Equal(t, s.ID, sid) + assert.Equal(t, entity.UserFixtures.Get("alice").UserUID, user.UserUID) + assert.True(t, user.CanUseWebDAV()) + + // WebDAVAuthSession should not set a status code or any headers. + assert.Equal(t, http.StatusOK, c.Writer.Status()) + assert.Equal(t, "", c.Writer.Header().Get("WWW-Authenticate")) + }) + t.Run("AliceTokenScope", func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = &http.Request{ + Header: make(http.Header), + } + + s := entity.SessionFixtures.Get("alice_token_scope") + + // Get session without sufficient authorization scope. + sess, user, sid, cached := WebDAVAuthSession(c, s.AuthToken()) + + // Check result. + assert.NotNil(t, sess) + assert.NotNil(t, user) + assert.Equal(t, s.ID, sid) + assert.False(t, cached) + assert.True(t, sess.HasUser()) + assert.Equal(t, user.UserUID, sess.UserUID) + assert.Equal(t, entity.UserFixtures.Get("alice").UserUID, user.UserUID) + assert.Equal(t, entity.UserFixtures.Get("alice").UserUID, sess.UserUID) + assert.True(t, user.CanUseWebDAV()) + assert.False(t, sess.HasScope(acl.ResourceWebDAV.String())) + + // WebDAVAuthSession should not set a status code or any headers. + assert.Equal(t, http.StatusOK, c.Writer.Status()) + assert.Equal(t, "", c.Writer.Header().Get("WWW-Authenticate")) + }) + t.Run("InvalidAuthSecret", func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = &http.Request{ + Header: make(http.Header), + } + + authToken := rnd.AuthSecret() + authId := rnd.SessionID(authToken) + + // Get session with invalid auth secret. + sess, user, sid, cached := WebDAVAuthSession(c, authToken) + + // Check result. + assert.Nil(t, sess) + assert.Nil(t, user) + assert.Equal(t, authId, sid) + assert.False(t, cached) + + // WebDAVAuthSession should not set a status code or any headers. + assert.Equal(t, http.StatusOK, c.Writer.Status()) + assert.Equal(t, "", c.Writer.Header().Get("WWW-Authenticate")) + }) +}