From 5811af4503e9974ff8c059a698b80da4a2e40326 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 21:22:50 +0200 Subject: [PATCH] feat(dav): accept Bearer access tokens on WebDAV auth (#3548) WebDAVAuth now falls back to the API JWT verifier when no Basic credentials are present, mounting token users at their 'my' root. OIDC-only users and API clients (rclone, davfs2, ChromeOS Files) no longer need a separate DAV password. - WWW-Authenticate advertises both Basic and Bearer - Scoped client tokens need Files.Read; missing Files.Write confines to read-only alongside the group/account read-only bits - Unguarded DavAccounts[0] accesses len-guarded for account-less users - stripPrefix defaults the mount root to cloudreve://my when no dav account exists IdP-token introspection (option 2 of the upstream request) is not included; it needs JWKS/userinfo plumbing against the provider. --- middleware/auth.go | 120 ++++++++++++++++------ middleware/davbearer_test.go | 194 +++++++++++++++++++++++++++++++++++ pkg/webdav/webdav.go | 14 ++- 3 files changed, 292 insertions(+), 36 deletions(-) create mode 100644 middleware/davbearer_test.go diff --git a/middleware/auth.go b/middleware/auth.go index 35c87e94..3b179ea7 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -3,6 +3,7 @@ package middleware import ( "crypto/subtle" "net/http" + "strings" "github.com/cloudreve/Cloudreve/v4/application/dependency" "github.com/cloudreve/Cloudreve/v4/ent" @@ -104,44 +105,77 @@ func LoginRequired() gin.HandlerFunc { // WebDAVAuth 验证WebDAV登录及权限 func WebDAVAuth() gin.HandlerFunc { return func(c *gin.Context) { + dep := dependency.FromContext(c) + l := dep.Logger() + username, password, ok := c.Request.BasicAuth() + var expectedUser *ent.User + bearerAuth := false if !ok { - // OPTIONS 请求不需要鉴权 - if c.Request.Method == http.MethodOptions { - c.Next() + // Bearer auth: accept Cloudreve API access tokens so OIDC-only + // users and API clients can mount without a DAV password (#3548). + if !strings.HasPrefix(c.GetHeader(auth.AuthorizationHeader), auth.TokenHeaderPrefix) { + // OPTIONS 请求不需要鉴权 + if c.Request.Method == http.MethodOptions { + c.Next() + return + } + c.Writer.Header().Add("WWW-Authenticate", `Basic realm="cloudreve"`) + c.Writer.Header().Add("WWW-Authenticate", `Bearer realm="cloudreve"`) + c.Status(http.StatusUnauthorized) + c.Abort() return } - c.Writer.Header()["WWW-Authenticate"] = []string{`Basic realm="cloudreve"`} - c.Status(http.StatusUnauthorized) - c.Abort() - return - } - dep := dependency.FromContext(c) - l := dep.Logger() - userClient := dep.UserClient() - expectedUser, err := userClient.GetActiveByDavAccount(c, username, password) - if err != nil { - if username == "" { - if u, err := userClient.GetByEmail(c, username); err == nil { - // Try login with known user but incorrect password, record audit log - SetUserCtxByUser(c, u) - } + if _, err := dep.TokenAuth().VerifyAndRetrieveUser(c); err != nil { + l.Debug("WebDAVAuth: bearer token rejected: %s", err) + c.Status(http.StatusUnauthorized) + c.Abort() + return } - l.Debug("WebDAVAuth: failed to get user %q with provided credential: %s", username, err) - c.Status(http.StatusUnauthorized) - c.Abort() - return - } + uid := inventory.UserIDFromContext(c) + if uid == 0 { + c.Status(http.StatusUnauthorized) + c.Abort() + return + } - // Validate dav account - accounts, err := expectedUser.Edges.DavAccountsOrErr() - if err != nil || len(accounts) == 0 { - l.Debug("WebDAVAuth: failed to get user dav accounts %q with provided credential: %s", username, err) - c.Status(http.StatusUnauthorized) - c.Abort() - return + if err := SetUserCtx(c, uid); err != nil { + l.Debug("WebDAVAuth: failed to load bearer user %d: %s", uid, err) + c.Status(http.StatusUnauthorized) + c.Abort() + return + } + + expectedUser = inventory.UserFromContext(c) + bearerAuth = true + } else { + userClient := dep.UserClient() + var err error + expectedUser, err = userClient.GetActiveByDavAccount(c, username, password) + if err != nil { + if username == "" { + if u, err := userClient.GetByEmail(c, username); err == nil { + // Try login with known user but incorrect password, record audit log + SetUserCtxByUser(c, u) + } + } + + l.Debug("WebDAVAuth: failed to get user %q with provided credential: %s", username, err) + c.Status(http.StatusUnauthorized) + c.Abort() + return + } + + // Validate dav account + accounts, err := expectedUser.Edges.DavAccountsOrErr() + if err != nil || len(accounts) == 0 { + l.Debug("WebDAVAuth: failed to get user dav accounts %q with provided credential: %s", username, err) + c.Status(http.StatusUnauthorized) + c.Abort() + return + } } // 用户组已启用WebDAV? @@ -160,9 +194,27 @@ func WebDAVAuth() gin.HandlerFunc { return } + // Scoped client tokens need at least Files.Read; tokens without + // Files.Write are confined to read-only access. + if bearerAuth { + if err := auth.CheckScope(c, types.ScopeFilesRead); err != nil { + c.Status(http.StatusForbidden) + c.Abort() + return + } + } + // 检查是否只读 - if expectedUser.Edges.DavAccounts[0].Options.Enabled(int(types.DavAccountReadOnly)) || - group.Permissions.Enabled(int(types.GroupPermissionWebDAVReadOnly)) { + readOnly := group.Permissions.Enabled(int(types.GroupPermissionWebDAVReadOnly)) + if len(expectedUser.Edges.DavAccounts) > 0 && + expectedUser.Edges.DavAccounts[0].Options.Enabled(int(types.DavAccountReadOnly)) { + readOnly = true + } + if bearerAuth && auth.CheckScope(c, types.ScopeFilesWrite) != nil { + readOnly = true + } + + if readOnly { switch c.Request.Method { case http.MethodDelete, http.MethodPut, "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK", "PROPPATCH": c.Status(http.StatusForbidden) @@ -171,7 +223,9 @@ func WebDAVAuth() gin.HandlerFunc { } } - SetUserCtxByUser(c, expectedUser) + if !bearerAuth { + SetUserCtxByUser(c, expectedUser) + } c.Next() } } diff --git a/middleware/davbearer_test.go b/middleware/davbearer_test.go new file mode 100644 index 00000000..c3619193 --- /dev/null +++ b/middleware/davbearer_test.go @@ -0,0 +1,194 @@ +package middleware + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/cloudreve/Cloudreve/v4/application/dependency" + "github.com/cloudreve/Cloudreve/v4/ent/enttest" + "github.com/cloudreve/Cloudreve/v4/inventory" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/auth" + "github.com/cloudreve/Cloudreve/v4/pkg/boolset" + "github.com/cloudreve/Cloudreve/v4/pkg/cache" + "github.com/cloudreve/Cloudreve/v4/pkg/logging" + "github.com/cloudreve/Cloudreve/v4/pkg/util" + "github.com/gin-gonic/gin" +) + +type stubTokenAuth struct { + uid int + scopes []string +} + +func (s stubTokenAuth) Issue(ctx context.Context, args *auth.IssueTokenArgs) (*auth.Token, error) { + return nil, nil +} + +func (s stubTokenAuth) VerifyAndRetrieveUser(c *gin.Context) (bool, error) { + if c.GetHeader(auth.AuthorizationHeader) == "Bearer good" { + util.WithValue(c, inventory.UserIDCtx{}, s.uid) + if s.scopes != nil { + util.WithValue(c, auth.ScopeContextKey{}, s.scopes) + } + } + return false, nil +} + +func (s stubTokenAuth) Refresh(ctx context.Context, refreshToken string) (*auth.Token, error) { + return nil, nil +} + +func (s stubTokenAuth) Claims(ctx context.Context, tokenStr string) (*auth.Claims, error) { + return nil, nil +} + +func newDavAuthDep(t *testing.T, groupPerms *boolset.BooleanSet, scopes []string) dependency.Dep { + t.Helper() + client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&_fk=1") + t.Cleanup(func() { client.Close() }) + + group := client.Group.Create(). + SetName("g"). + SetPermissions(groupPerms). + SaveX(context.Background()) + + u := client.User.Create(). + SetEmail("dav@test.com"). + SetNick("dav"). + SetStatus("active"). + SetPassword("x"). + SetGroup(group). + SaveX(context.Background()) + + return dependency.NewDependency( + dependency.WithKV(cache.NewMemoStore("", nil)), + dependency.WithLogger(logging.NewConsoleLogger(logging.LevelDebug)), + dependency.WithUserClient(inventory.NewUserClient(client)), + dependency.WithTokenAuth(stubTokenAuth{uid: u.ID, scopes: scopes}), + ) +} + +func newDavRequest(t *testing.T, dep dependency.Dep, method, authHeader string) (*gin.Context, *httptest.ResponseRecorder) { + t.Helper() + w := httptest.NewRecorder() + c := gin.CreateTestContextOnly(w, testEngine) + req := httptest.NewRequest(method, "/dav/file.txt", nil) + if authHeader != "" { + req.Header.Set("Authorization", authHeader) + } + c.Request = req.WithContext(context.WithValue(req.Context(), dependency.DepCtx{}, dep)) + return c, w +} + +func TestWebDAVBearerAuth(t *testing.T) { + gin.SetMode(gin.TestMode) + handler := WebDAVAuth() + + davPerms := &boolset.BooleanSet{} + boolset.Set(int(types.GroupPermissionWebDAV), true, davPerms) + readOnlyPerms := &boolset.BooleanSet{} + boolset.Set(int(types.GroupPermissionWebDAV), true, readOnlyPerms) + boolset.Set(int(types.GroupPermissionWebDAVReadOnly), true, readOnlyPerms) + + t.Run("valid bearer token authenticates", func(t *testing.T) { + dep := newDavAuthDep(t, davPerms, nil) + c, _ := newDavRequest(t, dep, http.MethodGet, "Bearer good") + handler(c) + if c.IsAborted() { + t.Fatal("valid bearer token was rejected") + } + u := inventory.UserFromContext(c) + if u == nil || u.Email != "dav@test.com" { + t.Fatalf("expected bearer user in ctx, got %+v", u) + } + }) + + t.Run("invalid bearer token rejected", func(t *testing.T) { + dep := newDavAuthDep(t, davPerms, nil) + c, _ := newDavRequest(t, dep, http.MethodGet, "Bearer bad") + handler(c) + if !c.IsAborted() || c.Writer.Status() != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", c.Writer.Status()) + } + }) + + t.Run("no credentials still issues basic+bearer challenge", func(t *testing.T) { + dep := newDavAuthDep(t, davPerms, nil) + c, w := newDavRequest(t, dep, http.MethodGet, "") + handler(c) + if c.Writer.Status() != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", c.Writer.Status()) + } + if challenges := w.Header().Values("WWW-Authenticate"); len(challenges) != 2 { + t.Fatalf("expected Basic+Bearer challenges, got %v", challenges) + } + }) + + t.Run("options without credentials passes", func(t *testing.T) { + dep := newDavAuthDep(t, davPerms, nil) + c, _ := newDavRequest(t, dep, http.MethodOptions, "") + handler(c) + if c.IsAborted() { + t.Fatal("unauthenticated OPTIONS was rejected") + } + }) + + t.Run("bearer user without group dav permission forbidden", func(t *testing.T) { + dep := newDavAuthDep(t, &boolset.BooleanSet{}, nil) + c, _ := newDavRequest(t, dep, http.MethodGet, "Bearer good") + handler(c) + if c.Writer.Status() != http.StatusForbidden { + t.Fatalf("expected 403, got %d", c.Writer.Status()) + } + }) + + t.Run("group read-only blocks bearer writes", func(t *testing.T) { + dep := newDavAuthDep(t, readOnlyPerms, nil) + c, _ := newDavRequest(t, dep, http.MethodPut, "Bearer good") + handler(c) + if c.Writer.Status() != http.StatusForbidden { + t.Fatalf("expected 403 for PUT, got %d", c.Writer.Status()) + } + c, _ = newDavRequest(t, dep, http.MethodGet, "Bearer good") + handler(c) + if c.IsAborted() { + t.Fatal("read-only bearer GET was rejected") + } + }) + + t.Run("scoped token without Files.Write is read-only", func(t *testing.T) { + dep := newDavAuthDep(t, davPerms, []string{types.ScopeFilesRead}) + c, _ := newDavRequest(t, dep, http.MethodDelete, "Bearer good") + handler(c) + if c.Writer.Status() != http.StatusForbidden { + t.Fatalf("expected 403 for DELETE, got %d", c.Writer.Status()) + } + c, _ = newDavRequest(t, dep, http.MethodGet, "Bearer good") + handler(c) + if c.IsAborted() { + t.Fatal("scoped read token GET was rejected") + } + }) + + t.Run("scoped token without Files.Read forbidden", func(t *testing.T) { + dep := newDavAuthDep(t, davPerms, []string{"User.Read"}) + c, _ := newDavRequest(t, dep, http.MethodGet, "Bearer good") + handler(c) + if c.Writer.Status() != http.StatusForbidden { + t.Fatalf("expected 403, got %d", c.Writer.Status()) + } + }) + + t.Run("basic auth path unchanged", func(t *testing.T) { + dep := newDavAuthDep(t, davPerms, nil) + c, _ := newDavRequest(t, dep, http.MethodGet, "Basic dGVzdDp0ZXN0") + handler(c) + // No dav account exists -> 401 via the basic credential path. + if c.Writer.Status() != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", c.Writer.Status()) + } + }) +} diff --git a/pkg/webdav/webdav.go b/pkg/webdav/webdav.go index c0ab7c4d..4489a0ca 100644 --- a/pkg/webdav/webdav.go +++ b/pkg/webdav/webdav.go @@ -41,7 +41,13 @@ const ( ) func stripPrefix(p string, u *ent.User) (string, *fs.URI, int, error) { - base, err := fs.NewUriFromString(u.Edges.DavAccounts[0].URI) + // Bearer-authenticated users carry no dav account; mount them at their + // "my" root. Basic-auth users keep their configured mount point (#3548). + baseUri := fs.NewMyUri("") + if len(u.Edges.DavAccounts) > 0 { + baseUri = u.Edges.DavAccounts[0].URI + } + base, err := fs.NewUriFromString(baseUri) if err != nil { return "", nil, http.StatusInternalServerError, err } @@ -262,7 +268,8 @@ func handlePut(c *gin.Context, user *ent.User, fm manager.FileManager) (status i return purposeStatusCodeFromError(err), err } - if user.Edges.DavAccounts[0].Options.Enabled(int(types.DavAccountDisableSysFiles)) { + if len(user.Edges.DavAccounts) > 0 && + user.Edges.DavAccounts[0].Options.Enabled(int(types.DavAccountDisableSysFiles)) { if strings.HasPrefix(reqPath.Name(), ".") { return http.StatusMethodNotAllowed, nil } @@ -560,7 +567,8 @@ func handleGetHeadPost(c *gin.Context, user *ent.User, fm manager.FileManager) ( es.Apply(entitysource.WithSpeedLimit(int64(user.Edges.Group.SpeedLimit))) if es.ShouldInternalProxy() || - (user.Edges.DavAccounts[0].Options.Enabled(int(types.DavAccountProxy)) && + (len(user.Edges.DavAccounts) > 0 && + user.Edges.DavAccounts[0].Options.Enabled(int(types.DavAccountProxy)) && user.Edges.Group.Permissions.Enabled(int(types.GroupPermissionWebDAVProxy))) { es.Serve(c.Writer, c.Request) } else {