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
parent
c9f5adee72
commit
5811af4503
@ -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())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Reference in new issue