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.
pull/3582/head
Tomas Dvorak 2 weeks ago
parent c9f5adee72
commit 5811af4503

@ -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,23 +105,55 @@ 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 {
// 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()["WWW-Authenticate"] = []string{`Basic realm="cloudreve"`}
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
}
dep := dependency.FromContext(c)
l := dep.Logger()
if _, err := dep.TokenAuth().VerifyAndRetrieveUser(c); err != nil {
l.Debug("WebDAVAuth: bearer token rejected: %s", err)
c.Status(http.StatusUnauthorized)
c.Abort()
return
}
uid := inventory.UserIDFromContext(c)
if uid == 0 {
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()
expectedUser, err := userClient.GetActiveByDavAccount(c, username, password)
var err error
expectedUser, err = userClient.GetActiveByDavAccount(c, username, password)
if err != nil {
if username == "" {
if u, err := userClient.GetByEmail(c, username); err == nil {
@ -143,6 +176,7 @@ func WebDAVAuth() gin.HandlerFunc {
c.Abort()
return
}
}
// 用户组已启用WebDAV?
group, err := expectedUser.Edges.GroupOrErr()
@ -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 {
}
}
if !bearerAuth {
SetUserCtxByUser(c, expectedUser)
}
c.Next()
}
}

@ -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())
}
})
}

@ -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 {

Loading…
Cancel
Save