Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
@@ -0,0 +1,37 @@
package middleware
import (
"net/http"
"time"
"github.com/go-chi/chi/v5/middleware"
"github.com/qsfera/server/pkg/log"
"go.opentelemetry.io/otel/trace"
)
// AccessLog is a middleware to log http requests at info level logging.
func AccessLog(logger log.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
requestID := middleware.GetReqID(r.Context())
// add Request Id to all responses
w.Header().Set(middleware.RequestIDHeader, requestID)
wrap := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(wrap, r)
spanContext := trace.SpanContextFromContext(r.Context())
logger.Info().
Str("proto", r.Proto).
Str(log.RequestIDString, requestID).
Str("traceid", spanContext.TraceID().String()).
Str("remote-addr", r.RemoteAddr).
Str("method", r.Method).
Int("status", wrap.Status()).
Str("path", r.URL.Path).
Dur("duration", time.Since(start)).
Int("bytes", wrap.BytesWritten()).
Msg("access-log")
})
}
}
@@ -0,0 +1,335 @@
package middleware
import (
"context"
"errors"
"fmt"
"net/http"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
tenantpb "github.com/cs3org/go-cs3apis/cs3/identity/tenant/v1beta1"
rpcpb "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
"github.com/jellydator/ttlcache/v3"
"github.com/qsfera/server/services/proxy/pkg/router"
"github.com/qsfera/server/services/proxy/pkg/user/backend"
"github.com/qsfera/server/services/proxy/pkg/userroles"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
cs3user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/oidc"
"github.com/qsfera/server/services/proxy/pkg/config"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/utils"
)
// AccountResolver provides a middleware which mints a jwt and adds it to the proxied request based
// on the oidc-claims
func AccountResolver(optionSetters ...Option) func(next http.Handler) http.Handler {
options := newOptions(optionSetters...)
logger := options.Logger
tracer := getTraceProvider(options).Tracer("proxy.middleware.account_resolver")
lastGroupSyncCache := ttlcache.New(
ttlcache.WithTTL[string, struct{}](5*time.Minute),
ttlcache.WithDisableTouchOnHit[string, struct{}](),
)
go lastGroupSyncCache.Start()
tenantIDCache := ttlcache.New(
ttlcache.WithTTL[string, string](10*time.Minute),
ttlcache.WithDisableTouchOnHit[string, string](),
)
go tenantIDCache.Start()
return func(next http.Handler) http.Handler {
return &accountResolver{
next: next,
logger: logger,
tracer: tracer,
userProvider: options.UserProvider,
userOIDCClaim: options.UserOIDCClaim,
userCS3Claim: options.UserCS3Claim,
tenantOIDCClaim: options.TenantOIDCClaim,
tenantIDMappingEnabled: options.TenantIDMappingEnabled,
gatewaySelector: options.RevaGatewaySelector,
serviceAccount: options.ServiceAccount,
userRoleAssigner: options.UserRoleAssigner,
autoProvisionAccounts: options.AutoprovisionAccounts,
multiTenantEnabled: options.MultiTenantEnabled,
lastGroupSyncCache: lastGroupSyncCache,
tenantIDCache: tenantIDCache,
eventsPublisher: options.EventsPublisher,
}
}
}
type accountResolver struct {
next http.Handler
logger log.Logger
tracer trace.Tracer
userProvider backend.UserBackend
userRoleAssigner userroles.UserRoleAssigner
autoProvisionAccounts bool
multiTenantEnabled bool
tenantIDMappingEnabled bool
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
serviceAccount config.ServiceAccount
userOIDCClaim string
userCS3Claim string
tenantOIDCClaim string
// lastGroupSyncCache is used to keep track of when the last sync of group
// memberships was done for a specific user. This is used to trigger a sync
// with every single request.
lastGroupSyncCache *ttlcache.Cache[string, struct{}]
// tenantIDCache maps external tenant IDs (from OIDC claims) to internal tenant IDs.
tenantIDCache *ttlcache.Cache[string, string]
eventsPublisher events.Publisher
}
func readStringClaim(path string, claims map[string]any) (string, error) {
// happy path
value, _ := claims[path].(string)
if value != "" {
return value, nil
}
// try splitting path at .
segments := oidc.SplitWithEscaping(path, ".", "\\")
subclaims := claims
lastSegment := len(segments) - 1
for i := range segments {
if i < lastSegment {
if castedClaims, ok := subclaims[segments[i]].(map[string]any); ok {
subclaims = castedClaims
} else if castedClaims, ok := subclaims[segments[i]].(map[any]any); ok {
subclaims = make(map[string]any, len(castedClaims))
for k, v := range castedClaims {
if s, ok := k.(string); ok {
subclaims[s] = v
} else {
return "", fmt.Errorf("could not walk claims path, key '%v' is not a string", k)
}
}
}
} else {
if value, _ = subclaims[segments[i]].(string); value != "" {
return value, nil
}
}
}
return value, fmt.Errorf("claim path '%s' not set or empty", path)
}
// TODO do not use the context to store values: https://medium.com/@cep21/how-to-correctly-use-context-context-in-go-1-7-8f2c0fafdf39
func (m accountResolver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
ctx, span := m.tracer.Start(req.Context(), fmt.Sprintf("%s %s", req.Method, req.URL.Path), trace.WithSpanKind(trace.SpanKindServer))
claims := oidc.FromContext(ctx)
user, ok := revactx.ContextGetUser(ctx)
token, hasToken := revactx.ContextGetToken(ctx)
req = req.WithContext(ctx)
defer span.End()
if claims == nil && !ok {
span.End()
m.next.ServeHTTP(w, req)
return
}
if user == nil && claims != nil {
value, err := readStringClaim(m.userOIDCClaim, claims)
if err != nil {
m.logger.Error().Err(err).Msg("could not read user id claim")
w.WriteHeader(http.StatusInternalServerError)
return
}
user, token, err = m.userProvider.GetUserByClaims(req.Context(), m.userCS3Claim, value)
if errors.Is(err, backend.ErrAccountNotFound) {
m.logger.Debug().Str("claim", m.userOIDCClaim).Str("value", value).Msg("User by claim not found")
if !m.autoProvisionAccounts {
m.logger.Debug().Interface("claims", claims).Msg("Autoprovisioning disabled")
w.WriteHeader(http.StatusUnauthorized)
return
}
m.logger.Debug().Interface("claims", claims).Msg("Autoprovisioning user")
var newuser *cs3user.User
newuser, err = m.userProvider.CreateUserFromClaims(req.Context(), claims)
if err != nil {
m.logger.Error().Err(err).Msg("Autoprovisioning user failed")
w.WriteHeader(http.StatusInternalServerError)
return
}
user, token, err = m.userProvider.GetUserByClaims(req.Context(), "userid", newuser.Id.OpaqueId)
if err != nil {
m.logger.Error().Err(err).Str("userid", newuser.Id.OpaqueId).Msg("Error getting token for autoprovisioned user")
w.WriteHeader(http.StatusUnauthorized)
return
}
}
if errors.Is(err, backend.ErrAccountDisabled) {
m.logger.Debug().Interface("claims", claims).Msg("Disabled")
w.WriteHeader(http.StatusUnauthorized)
return
}
if err != nil {
m.logger.Error().Err(err).Msg("Could not get user by claim")
w.WriteHeader(http.StatusInternalServerError)
return
}
// if this is a multi-tenant setup, make sure the resolved user has a tenant id set
if m.multiTenantEnabled && user.GetId().GetTenantId() == "" {
m.logger.Error().Str("userid", user.Id.OpaqueId).Msg("User does not have a tenantId assigned")
w.WriteHeader(http.StatusUnauthorized)
return
}
// if a tenant claim is configured, verify it matches the tenant id on the resolved user
if m.tenantOIDCClaim != "" {
if err = m.verifyTenantClaim(req.Context(), user.GetId().GetTenantId(), claims); err != nil {
m.logger.Error().Err(err).Str("userid", user.GetId().GetOpaqueId()).Msg("Tenant claim mismatch")
w.WriteHeader(http.StatusUnauthorized)
return
}
}
// update user if needed
if m.autoProvisionAccounts {
if err = m.userProvider.UpdateUserIfNeeded(req.Context(), user, claims); err != nil {
m.logger.Error().Err(err).Str("userid", user.GetId().GetOpaqueId()).Interface("claims", claims).Msg("Failed to update autoprovisioned user")
w.WriteHeader(http.StatusInternalServerError)
return
}
// Only sync group memberships if the user has not been synced since the last cache invalidation
if !m.lastGroupSyncCache.Has(user.GetId().GetOpaqueId()) {
if err = m.userProvider.SyncGroupMemberships(req.Context(), user, claims); err != nil {
m.logger.Error().Err(err).Str("userid", user.GetId().GetOpaqueId()).Interface("claims", claims).Msg("Failed to sync group memberships for autoprovisioned user")
w.WriteHeader(http.StatusInternalServerError)
return
}
m.lastGroupSyncCache.Set(user.GetId().GetOpaqueId(), struct{}{}, ttlcache.DefaultTTL)
}
}
// resolve the user's roles
user, err = m.userRoleAssigner.UpdateUserRoleAssignment(ctx, user, claims)
if err != nil {
m.logger.Error().Err(err).Msg("Could not get user roles")
w.WriteHeader(http.StatusInternalServerError)
return
}
// If this is a new session, publish user login event
if newSession := oidc.NewSessionFlagFromContext(ctx); newSession && m.eventsPublisher != nil {
event := events.UserSignedIn{
Executant: user.Id,
Timestamp: utils.TimeToTS(time.Now()),
}
if err := events.Publish(req.Context(), m.eventsPublisher, event); err != nil {
m.logger.Error().Err(err).Msg("could not publish user signin event.")
}
}
// add user to context for selectors
ctx = revactx.ContextSetUser(ctx, user)
req = req.WithContext(ctx)
m.logger.Debug().Interface("claims", claims).Interface("user", user).Msg("associated claims with user")
} else if user != nil && !hasToken {
// if this is a multi-tenant setup, make sure the resolved user has a tenant id set
if m.multiTenantEnabled && user.GetId().GetTenantId() == "" {
m.logger.Error().Str("userid", user.Id.OpaqueId).Msg("User does not have a tenantId assigned")
w.WriteHeader(http.StatusUnauthorized)
return
}
// If we already have a token (e.g. the app auth middleware adds the token to the context) there is no need
// to get yet another one here.
var err error
_, token, err = m.userProvider.GetUserByClaims(req.Context(), "username", user.Username)
if errors.Is(err, backend.ErrAccountDisabled) {
m.logger.Debug().Interface("user", user).Msg("Disabled")
w.WriteHeader(http.StatusUnauthorized)
return
}
if err != nil {
m.logger.Error().Err(err).Msg("Could not get user by claim")
w.WriteHeader(http.StatusInternalServerError)
return
}
}
span.SetAttributes(attribute.String("enduser.id", user.GetId().GetOpaqueId()))
ri := router.ContextRoutingInfo(ctx)
if ri.RemoteUserHeader() != "" {
req.Header.Set(ri.RemoteUserHeader(), user.GetId().GetOpaqueId())
}
if !ri.SkipXAccessToken() {
req.Header.Set(revactx.TokenHeader, token)
}
span.End()
m.next.ServeHTTP(w, req)
}
func (m accountResolver) verifyTenantClaim(ctx context.Context, userTenantID string, claims map[string]any) error {
claimTenantID, err := readStringClaim(m.tenantOIDCClaim, claims)
if err != nil {
return fmt.Errorf("could not read tenant claim: %w", err)
}
internalTenantID := claimTenantID
if m.tenantIDMappingEnabled {
internalTenantID, err = m.resolveInternalTenantID(ctx, claimTenantID)
if err != nil {
return fmt.Errorf("could not resolve internal tenant id for external tenant id %q: %w", claimTenantID, err)
}
}
if internalTenantID != userTenantID {
return fmt.Errorf("tenant id from claim %q does not match user tenant id %q", claimTenantID, userTenantID)
}
return nil
}
// resolveInternalTenantID maps an external tenant ID (as it appears in OIDC claims) to the
// internal tenant ID stored on the user object by calling the gateway's TenantAPI.
// Results are cached for 10 minutes to avoid repeated lookups on every request.
// The call is authenticated using the configured service account.
func (m accountResolver) resolveInternalTenantID(ctx context.Context, externalTenantID string) (string, error) {
if item := m.tenantIDCache.Get(externalTenantID); item != nil {
return item.Value(), nil
}
gwc, err := m.gatewaySelector.Next()
if err != nil {
return "", fmt.Errorf("could not get gateway client: %w", err)
}
authCtx, err := utils.GetServiceUserContextWithContext(ctx, gwc, m.serviceAccount.ServiceAccountID, m.serviceAccount.ServiceAccountSecret)
if err != nil {
return "", fmt.Errorf("could not authenticate service account: %w", err)
}
resp, err := gwc.GetTenantByClaim(authCtx, &tenantpb.GetTenantByClaimRequest{
Claim: "externalid",
Value: externalTenantID,
})
if err != nil {
return "", err
}
if resp.GetStatus().GetCode() != rpcpb.Code_CODE_OK {
return "", fmt.Errorf("TenantAPI returned status %s: %s", resp.GetStatus().GetCode(), resp.GetStatus().GetMessage())
}
internalID := resp.GetTenant().GetId()
m.tenantIDCache.Set(externalTenantID, internalID, ttlcache.DefaultTTL)
return internalID, nil
}
@@ -0,0 +1,468 @@
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"testing"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
tenantpb "github.com/cs3org/go-cs3apis/cs3/identity/tenant/v1beta1"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
rpcpb "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/oidc"
"github.com/qsfera/server/services/proxy/pkg/config"
"github.com/qsfera/server/services/proxy/pkg/router"
"github.com/qsfera/server/services/proxy/pkg/user/backend"
"github.com/qsfera/server/services/proxy/pkg/user/backend/mocks"
userRoleMocks "github.com/qsfera/server/services/proxy/pkg/userroles/mocks"
"github.com/opencloud-eu/reva/v2/pkg/auth/scope"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/token/manager/jwt"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
)
const (
testIdP = "https://idx.example.com"
testTenantA = "tenant-a"
testTenantB = "tenant-b"
testJWTSecret = "change-me"
testSvcAccountID = "svc-account-id"
testSvcAccountSecret = "svc-account-secret"
testSvcAccountToken = "svc-account-token"
)
func TestTokenIsAddedWithMailClaim(t *testing.T) {
sut := newMockAccountResolver(&userv1beta1.User{
Id: &userv1beta1.UserId{Idp: testIdP, OpaqueId: "123"},
Mail: "foo@example.com",
}, nil, oidc.Email, "mail", false)
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
oidc.Email: "foo@example.com",
})
sut.ServeHTTP(rw, req)
token := req.Header.Get(revactx.TokenHeader)
assert.NotEmpty(t, token)
assert.Contains(t, token, "eyJ")
}
func TestTokenIsAddedWithUsernameClaim(t *testing.T) {
sut := newMockAccountResolver(&userv1beta1.User{
Id: &userv1beta1.UserId{Idp: testIdP, OpaqueId: "123"},
Mail: "foo@example.com",
}, nil, oidc.PreferredUsername, "username", false)
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
oidc.PreferredUsername: "foo",
})
sut.ServeHTTP(rw, req)
token := req.Header.Get(revactx.TokenHeader)
assert.NotEmpty(t, token)
assert.Contains(t, token, "eyJ")
}
func TestTokenIsAddedWithDotUsernamePathClaim(t *testing.T) {
sut := newMockAccountResolver(&userv1beta1.User{
Id: &userv1beta1.UserId{Idp: testIdP, OpaqueId: "123"},
Mail: "foo@example.com",
}, nil, "li.un", "username", false)
// This is how lico adds the username to the access token
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
"li": map[string]any{
"un": "foo",
},
})
sut.ServeHTTP(rw, req)
token := req.Header.Get(revactx.TokenHeader)
assert.NotEmpty(t, token)
assert.Contains(t, token, "eyJ")
}
func TestTokenIsAddedWithDottedUsernameClaim(t *testing.T) {
tests := []struct {
name string
oidcClaim string
// comment describing what the claim exercises
desc string
}{
{
name: "escaped dot treated as literal key",
oidcClaim: "li\\.un",
desc: "li\\.un escapes the dot so the claim is looked up as the literal key \"li.un\"",
},
{
name: "dotted path falls back to literal key",
oidcClaim: "li.un",
desc: "li.un is first tried as a nested path; when \"un\" is absent under \"li\", it falls back to the literal key \"li.un\"",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
sut := newMockAccountResolver(&userv1beta1.User{
Id: &userv1beta1.UserId{Idp: testIdP, OpaqueId: "123"},
Mail: "foo@example.com",
}, nil, tc.oidcClaim, "username", false)
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
"li.un": "foo",
})
sut.ServeHTTP(rw, req)
token := req.Header.Get(revactx.TokenHeader)
assert.NotEmpty(t, token)
assert.Contains(t, token, "eyJ")
})
}
}
func TestNSkipOnNoClaims(t *testing.T) {
sut := newMockAccountResolver(nil, backend.ErrAccountDisabled, oidc.Email, "mail", false)
req, rw := mockRequest(nil)
sut.ServeHTTP(rw, req)
token := req.Header.Get("x-access-token")
assert.Empty(t, token)
assert.Equal(t, http.StatusOK, rw.Code)
}
func TestUnauthorizedOnUserNotFound(t *testing.T) {
sut := newMockAccountResolver(nil, backend.ErrAccountNotFound, oidc.PreferredUsername, "username", false)
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
oidc.PreferredUsername: "foo",
})
sut.ServeHTTP(rw, req)
token := req.Header.Get(revactx.TokenHeader)
assert.Empty(t, token)
assert.Equal(t, http.StatusUnauthorized, rw.Code)
}
func TestUnauthorizedOnUserDisabled(t *testing.T) {
sut := newMockAccountResolver(nil, backend.ErrAccountDisabled, oidc.PreferredUsername, "username", false)
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
oidc.PreferredUsername: "foo",
})
sut.ServeHTTP(rw, req)
token := req.Header.Get(revactx.TokenHeader)
assert.Empty(t, token)
assert.Equal(t, http.StatusUnauthorized, rw.Code)
}
func TestInternalServerErrorOnMissingMailAndUsername(t *testing.T) {
sut := newMockAccountResolver(nil, backend.ErrAccountNotFound, oidc.Email, "mail", false)
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
})
sut.ServeHTTP(rw, req)
token := req.Header.Get(revactx.TokenHeader)
assert.Empty(t, token)
assert.Equal(t, http.StatusInternalServerError, rw.Code)
}
func TestUnauthorizedOnMissingTenantId(t *testing.T) {
sut := newMockAccountResolver(
&userv1beta1.User{
Id: &userv1beta1.UserId{Idp: testIdP, OpaqueId: "123"},
Username: "foo",
},
nil, oidc.PreferredUsername, "username", true)
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
oidc.PreferredUsername: "foo",
})
sut.ServeHTTP(rw, req)
token := req.Header.Get(revactx.TokenHeader)
assert.Empty(t, token)
assert.Equal(t, http.StatusUnauthorized, rw.Code)
}
func TestTokenIsAddedWhenUserHasTenantId(t *testing.T) {
sut := newMockAccountResolver(
&userv1beta1.User{
Id: &userv1beta1.UserId{
Idp: testIdP,
OpaqueId: "123",
TenantId: "tenant1",
},
Username: "foo",
},
nil, oidc.PreferredUsername, "username", true)
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
oidc.PreferredUsername: "foo",
})
sut.ServeHTTP(rw, req)
token := req.Header.Get(revactx.TokenHeader)
assert.NotEmpty(t, token)
assert.Contains(t, token, "eyJ")
}
func TestTenantClaimValidation(t *testing.T) {
tests := []struct {
name string
requestTenant string
wantToken bool
wantStatusCode int
}{
{
name: "token added when tenant claim matches",
requestTenant: testTenantA,
wantToken: true,
wantStatusCode: http.StatusOK,
},
{
name: "unauthorized when tenant claim does not match",
requestTenant: testTenantB,
wantToken: false,
wantStatusCode: http.StatusUnauthorized,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
user := &userv1beta1.User{
Id: &userv1beta1.UserId{
Idp: testIdP,
OpaqueId: "123",
TenantId: testTenantA,
},
Username: "foo",
}
tokenManager, _ := jwt.New(map[string]any{"secret": testJWTSecret, "expires": int64(60)})
s, _ := scope.AddOwnerScope(nil)
token, _ := tokenManager.MintToken(context.Background(), user, s)
ub := mocks.UserBackend{}
ub.On("GetUserByClaims", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(user, token, nil)
ra := userRoleMocks.UserRoleAssigner{}
ra.On("UpdateUserRoleAssignment", mock.Anything, mock.Anything, mock.Anything).Return(user, nil)
sut := AccountResolver(
Logger(log.NewLogger()),
UserProvider(&ub),
UserRoleAssigner(&ra),
UserOIDCClaim(oidc.PreferredUsername),
UserCS3Claim("username"),
TenantOIDCClaim("tenant_id"),
MultiTenantEnabled(true),
)(mockHandler{})
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
oidc.PreferredUsername: "foo",
"tenant_id": tc.requestTenant,
})
sut.ServeHTTP(rw, req)
if tc.wantToken {
assert.NotEmpty(t, req.Header.Get(revactx.TokenHeader))
} else {
assert.Empty(t, req.Header.Get(revactx.TokenHeader))
}
assert.Equal(t, tc.wantStatusCode, rw.Code)
})
}
}
func newMockAccountResolver(userBackendResult *userv1beta1.User, userBackendErr error, oidcclaim, cs3claim string, multiTenant bool) http.Handler {
tokenManager, _ := jwt.New(map[string]any{
"secret": testJWTSecret,
"expires": int64(60),
})
token := ""
if userBackendResult != nil {
s, _ := scope.AddOwnerScope(nil)
token, _ = tokenManager.MintToken(context.Background(), userBackendResult, s)
}
ub := mocks.UserBackend{}
ub.On("GetUserByClaims", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(userBackendResult, token, userBackendErr)
ub.On("GetUserRoles", mock.Anything, mock.Anything).Return(userBackendResult, nil)
ra := userRoleMocks.UserRoleAssigner{}
ra.On("UpdateUserRoleAssignment", mock.Anything, mock.Anything, mock.Anything).Return(userBackendResult, nil)
return AccountResolver(
Logger(log.NewLogger()),
UserProvider(&ub),
UserRoleAssigner(&ra),
SkipUserInfo(false),
UserOIDCClaim(oidcclaim),
UserCS3Claim(cs3claim),
AutoprovisionAccounts(false),
MultiTenantEnabled(multiTenant),
)(mockHandler{})
}
func mockRequest(claims map[string]any) (*http.Request, *httptest.ResponseRecorder) {
if claims == nil {
return httptest.NewRequest("GET", "http://example.com/foo", nil), httptest.NewRecorder()
}
ctx := oidc.NewContext(context.Background(), claims)
ctx = router.SetRoutingInfo(ctx, router.RoutingInfo{})
req := httptest.NewRequest("GET", "http://example.com/foo", nil).WithContext(ctx)
rw := httptest.NewRecorder()
return req, rw
}
type mockHandler struct{}
func (m mockHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {}
func TestTenantIDMapping(t *testing.T) {
const (
externalTenantID = "external-tenant-x"
internalTenantID = testTenantA
)
user := &userv1beta1.User{
Id: &userv1beta1.UserId{
Idp: testIdP,
OpaqueId: "123",
TenantId: internalTenantID,
},
Username: "foo",
}
tokenManager, _ := jwt.New(map[string]any{"secret": testJWTSecret, "expires": int64(60)})
s, _ := scope.AddOwnerScope(nil)
token, _ := tokenManager.MintToken(context.Background(), user, s)
newSUT := func(t *testing.T, gatewayClient gateway.GatewayAPIClient) http.Handler {
t.Helper()
gatewaySelector := pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return gatewayClient
},
)
t.Cleanup(func() { pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway") })
ub := mocks.UserBackend{}
ub.On("GetUserByClaims", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(user, token, nil)
ra := userRoleMocks.UserRoleAssigner{}
ra.On("UpdateUserRoleAssignment", mock.Anything, mock.Anything, mock.Anything).Return(user, nil)
return AccountResolver(
Logger(log.NewLogger()),
UserProvider(&ub),
UserRoleAssigner(&ra),
UserOIDCClaim(oidc.PreferredUsername),
UserCS3Claim("username"),
TenantOIDCClaim("tenant_id"),
MultiTenantEnabled(true),
TenantIDMappingEnabled(true),
ServiceAccount(config.ServiceAccount{
ServiceAccountID: testSvcAccountID,
ServiceAccountSecret: testSvcAccountSecret,
}),
WithRevaGatewaySelector(gatewaySelector),
)(mockHandler{})
}
tests := []struct {
name string
tenantResponse *tenantpb.GetTenantByClaimResponse
wantToken bool
wantStatusCode int
}{
{
name: "token added when external tenant maps to user internal tenant",
tenantResponse: &tenantpb.GetTenantByClaimResponse{
Status: &rpcpb.Status{Code: rpcpb.Code_CODE_OK},
Tenant: &tenantpb.Tenant{Id: internalTenantID, ExternalId: externalTenantID},
},
wantToken: true,
wantStatusCode: http.StatusOK,
},
{
name: "unauthorized when external tenant maps to a different internal tenant",
tenantResponse: &tenantpb.GetTenantByClaimResponse{
Status: &rpcpb.Status{Code: rpcpb.Code_CODE_OK},
Tenant: &tenantpb.Tenant{Id: testTenantB, ExternalId: externalTenantID},
},
wantToken: false,
wantStatusCode: http.StatusUnauthorized,
},
{
name: "unauthorized when external tenant is not found",
tenantResponse: &tenantpb.GetTenantByClaimResponse{
Status: &rpcpb.Status{Code: rpcpb.Code_CODE_NOT_FOUND, Message: "not found"},
},
wantToken: false,
wantStatusCode: http.StatusUnauthorized,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
gwc := &cs3mocks.GatewayAPIClient{}
gwc.On("Authenticate", mock.Anything, &gateway.AuthenticateRequest{
Type: "serviceaccounts",
ClientId: testSvcAccountID,
ClientSecret: testSvcAccountSecret,
}).Return(&gateway.AuthenticateResponse{
Status: &rpcpb.Status{Code: rpcpb.Code_CODE_OK},
Token: testSvcAccountToken,
}, nil)
gwc.On("GetTenantByClaim", mock.Anything, &tenantpb.GetTenantByClaimRequest{
Claim: "externalid",
Value: externalTenantID,
}).Return(tc.tenantResponse, nil)
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
oidc.PreferredUsername: "foo",
"tenant_id": externalTenantID,
})
newSUT(t, gwc).ServeHTTP(rw, req)
if tc.wantToken {
assert.NotEmpty(t, req.Header.Get(revactx.TokenHeader))
} else {
assert.Empty(t, req.Header.Get(revactx.TokenHeader))
}
assert.Equal(t, tc.wantStatusCode, rw.Code)
gwc.AssertExpectations(t)
})
}
}
@@ -0,0 +1,64 @@
package middleware
import (
"net/http"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/proxy/pkg/userroles"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
)
// AppAuthAuthenticator defines the app auth authenticator
type AppAuthAuthenticator struct {
Logger log.Logger
RevaGatewaySelector pool.Selectable[gateway.GatewayAPIClient]
UserRoleAssigner userroles.UserRoleAssigner
}
// Authenticate implements the authenticator interface to authenticate requests via app auth.
func (m AppAuthAuthenticator) Authenticate(r *http.Request) (*http.Request, bool) {
if isPublicPath(r.URL.Path) {
// The authentication of public path requests is handled by another authenticator.
// Since we can't guarantee the order of execution of the authenticators, we better
// implement an early return here for paths we can't authenticate in this authenticator.
return nil, false
}
username, password, ok := r.BasicAuth()
if !ok {
return nil, false
}
next, err := m.RevaGatewaySelector.Next()
if err != nil {
return nil, false
}
authenticateResponse, err := next.Authenticate(r.Context(), &gateway.AuthenticateRequest{
Type: "appauth",
ClientId: username,
ClientSecret: password,
})
if err != nil {
return nil, false
}
if authenticateResponse.GetStatus().GetCode() != cs3rpc.Code_CODE_OK {
m.Logger.Debug().Str("msg", authenticateResponse.GetStatus().GetMessage()).Str("clientid", username).Msg("app auth failed")
return nil, false
}
user := authenticateResponse.GetUser()
if user, err = m.UserRoleAssigner.ApplyUserRole(r.Context(), user); err != nil {
m.Logger.Error().Err(err).Str("clientid", username).Msg("app auth: failed to load user roles")
return nil, false
}
ctx := revactx.ContextSetUser(r.Context(), user)
ctx = revactx.ContextSetToken(ctx, authenticateResponse.GetToken())
r = r.WithContext(ctx)
return r, true
}
@@ -0,0 +1,80 @@
package middleware
import (
"net/http"
"net/http/httptest"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/qsfera/server/pkg/log"
userRoleMocks "github.com/qsfera/server/services/proxy/pkg/userroles/mocks"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
)
var _ = Describe("Authenticating requests", Label("AppAuthAuthenticator"), func() {
var authenticator Authenticator
BeforeEach(func() {
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
ra := &userRoleMocks.UserRoleAssigner{}
ra.On("ApplyUserRole", mock.Anything, mock.Anything, mock.Anything).Return(&userv1beta1.User{}, nil)
authenticator = AppAuthAuthenticator{
Logger: log.NewLogger(),
RevaGatewaySelector: pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return mockGatewayClient{
AuthenticateFunc: func(authType, clientID, clientSecret string) (string, rpcv1beta1.Code) {
if authType != "appauth" {
return "", rpcv1beta1.Code_CODE_NOT_FOUND
}
if clientID == "test-user" && clientSecret == "AppPassword" {
return "reva-token", rpcv1beta1.Code_CODE_OK
}
return "", rpcv1beta1.Code_CODE_NOT_FOUND
},
}
},
),
UserRoleAssigner: ra,
}
})
When("the request contains correct data", func() {
It("should successfully authenticate", func() {
req := httptest.NewRequest(http.MethodGet, "http://example.com/example/path", http.NoBody)
req.SetBasicAuth("test-user", "AppPassword")
req2, valid := authenticator.Authenticate(req)
Expect(valid).To(Equal(true))
Expect(req2).ToNot(BeNil())
user, ok := revactx.ContextGetUser(req2.Context())
Expect(ok).To(BeTrue())
Expect(user).ToNot(BeNil())
token, ok := revactx.ContextGetToken(req2.Context())
Expect(ok).To(BeTrue())
Expect(token).To(Equal("reva-token"))
})
})
When("the request contains incorrect data", func() {
It("should not successfully authenticate", func() {
req := httptest.NewRequest(http.MethodGet, "http://example.com/example/path", http.NoBody)
req.SetBasicAuth("test-user", "WrongAppPassword")
req2, valid := authenticator.Authenticate(req)
Expect(valid).To(Equal(false))
Expect(req2).To(BeNil())
})
})
})
@@ -0,0 +1,216 @@
package middleware
import (
"fmt"
"io"
"net/http"
"regexp"
"strings"
"github.com/qsfera/server/services/proxy/pkg/router"
"github.com/qsfera/server/services/proxy/pkg/webdav"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
var (
// SupportedAuthStrategies stores configured challenges.
SupportedAuthStrategies []string
// ProxyWwwAuthenticate is a list of endpoints that do not rely on reva underlying authentication, such as ocs.
// services that fallback to reva authentication are declared in the "frontend" command on КуСфера. It is a list of
// regexp.Regexp which are safe to use concurrently.
ProxyWwwAuthenticate = []regexp.Regexp{*regexp.MustCompile("/ocs/v[12].php/cloud/")}
_publicPaths = [...]string{
"/dav/public-files/",
"/remote.php/dav/ocm/",
"/dav/ocm/",
"/ocm/",
"/remote.php/dav/public-files/",
"/ocs/v1.php/apps/files_sharing/api/v1/tokeninfo/unprotected",
"/ocs/v2.php/apps/files_sharing/api/v1/tokeninfo/unprotected",
"/ocs/v1.php/cloud/capabilities",
}
)
const (
// WwwAuthenticate captures the Www-Authenticate header string.
WwwAuthenticate = "Www-Authenticate"
)
// Authenticator is the common interface implemented by all request authenticators.
type Authenticator interface {
// Authenticate is used to authenticate incoming HTTP requests.
// The Authenticator may augment the request with user info or anything related to the
// authentication and return the augmented request.
Authenticate(*http.Request) (*http.Request, bool)
}
// Authentication is a higher order authentication middleware.
func Authentication(auths []Authenticator, opts ...Option) func(next http.Handler) http.Handler {
options := newOptions(opts...)
configureSupportedChallenges(options)
tracer := getTraceProvider(options).Tracer("proxy.middleware.authentication")
spanOpts := []trace.SpanStartOption{
trace.WithSpanKind(trace.SpanKindServer),
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), fmt.Sprintf("%s %s", r.Method, r.URL.Path), spanOpts...)
r = r.WithContext(ctx)
defer span.End()
ri := router.ContextRoutingInfo(ctx)
if isOIDCTokenAuth(r) || ri.IsRouteUnprotected() || r.Method == "OPTIONS" {
// Either this is a request that does not need any authentication or
// the authentication for this request is handled by the IdP.
span.SetAttributes(attribute.Bool("routeunprotected", true))
span.End()
next.ServeHTTP(w, r)
return
}
for _, a := range auths {
if req, ok := a.Authenticate(r); ok {
span.End()
next.ServeHTTP(w, req)
return
}
}
if !isPublicPath(r.URL.Path) {
// Failed basic authentication attempts receive the Www-Authenticate header in the response
var touch bool
caser := cases.Title(language.Und)
for k, v := range options.CredentialsByUserAgent {
if strings.Contains(k, r.UserAgent()) {
removeSuperfluousAuthenticate(w)
w.Header().Add("Www-Authenticate", fmt.Sprintf("%v realm=\"%s\", charset=\"UTF-8\"", caser.String(v), r.Host))
touch = true
break
}
}
// if the request is not bound to any user agent, write all available challenges
if !touch {
writeSupportedAuthenticateHeader(w, r)
}
}
for _, s := range SupportedAuthStrategies {
userAgentAuthenticateLockIn(w, r, options.CredentialsByUserAgent, s)
}
w.WriteHeader(http.StatusUnauthorized)
// if the request is a PROPFIND return a WebDAV error code.
// TODO: The proxy has to be smart enough to detect when a request is directed towards a webdav server
// and react accordingly.
if webdav.IsWebdavRequest(r) {
b, err := webdav.Marshal(webdav.Exception{
Code: webdav.SabredavPermissionDenied,
Message: "Authentication error",
})
webdav.HandleWebdavError(w, b, err)
}
if r.ProtoMajor == 1 {
// https://github.com/owncloud/ocis/issues/5066
// https://github.com/golang/go/blob/d5de62df152baf4de6e9fe81933319b86fd95ae4/src/net/http/server.go#L1357-L1417
// https://github.com/golang/go/issues/15527
defer r.Body.Close()
_, _ = io.Copy(io.Discard, r.Body)
}
})
}
}
// The token auth endpoint uses basic auth for clients, see https://openid.net/specs/openid-connect-basic-1_0.html#TokenRequest
// > The Client MUST authenticate to the Token Endpoint using the HTTP Basic method, as described in 2.3.1 of OAuth 2.0.
func isOIDCTokenAuth(req *http.Request) bool {
return req.URL.Path == "/konnect/v1/token"
}
func isPublicPath(p string) bool {
for _, pp := range _publicPaths {
if strings.HasPrefix(p, pp) {
return true
}
}
return false
}
// configureSupportedChallenges adds known authentication challenges to the current session.
func configureSupportedChallenges(options Options) {
if options.OIDCIss != "" {
SupportedAuthStrategies = append(SupportedAuthStrategies, "bearer")
}
if options.EnableBasicAuth {
SupportedAuthStrategies = append(SupportedAuthStrategies, "basic")
}
}
func writeSupportedAuthenticateHeader(w http.ResponseWriter, r *http.Request) {
caser := cases.Title(language.Und)
for _, s := range SupportedAuthStrategies {
if r.Header.Get("X-Requested-With") != "XMLHttpRequest" {
w.Header().Add(WwwAuthenticate, fmt.Sprintf("%v realm=\"%s\", charset=\"UTF-8\"", caser.String(s), r.Host))
}
}
}
func removeSuperfluousAuthenticate(w http.ResponseWriter) {
w.Header().Del(WwwAuthenticate)
}
// userAgentLocker aids in dependency injection for helper methods. The set of fields is arbitrary and the only relation
// they share is to fulfill their duty and lock a User-Agent to its correct challenge if configured.
type userAgentLocker struct {
w http.ResponseWriter
r *http.Request
locks map[string]string // locks represents a reva user-agent:challenge mapping.
fallback string
}
// userAgentAuthenticateLockIn sets Www-Authenticate according to configured user agents. This is useful for the case of
// legacy clients that do not support protocols like OIDC or OAuth and want to lock a given user agent to a challenge
// such as basic. For more context check https://github.com/cs3org/reva/pull/1350
func userAgentAuthenticateLockIn(w http.ResponseWriter, r *http.Request, locks map[string]string, fallback string) {
u := userAgentLocker{
w: w,
r: r,
locks: locks,
fallback: fallback,
}
for _, r := range ProxyWwwAuthenticate {
evalRequestURI(u, r)
}
}
func evalRequestURI(l userAgentLocker, r regexp.Regexp) {
if !r.MatchString(l.r.RequestURI) {
return
}
caser := cases.Title(language.Und)
for k, v := range l.locks {
if strings.Contains(k, l.r.UserAgent()) {
removeSuperfluousAuthenticate(l.w)
l.w.Header().Add(WwwAuthenticate, fmt.Sprintf("%v realm=\"%s\", charset=\"UTF-8\"", caser.String(v), l.r.Host))
return
}
}
}
func getTraceProvider(o Options) trace.TracerProvider {
if o.TraceProvider != nil {
return o.TraceProvider
}
return trace.NewNoopTracerProvider()
}
@@ -0,0 +1,203 @@
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
"github.com/golang-jwt/jwt/v5"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/oidc"
oidcmocks "github.com/qsfera/server/pkg/oidc/mocks"
"github.com/qsfera/server/services/proxy/pkg/router"
"github.com/qsfera/server/services/proxy/pkg/user/backend"
"github.com/qsfera/server/services/proxy/pkg/user/backend/mocks"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/stretchr/testify/mock"
"go-micro.dev/v4/store"
"google.golang.org/grpc"
)
var _ = Describe("authentication helpers", func() {
DescribeTable("isPublicPath should recognize public paths",
func(input string, expected bool) {
isPublic := isPublicPath(input)
Expect(isPublic).To(Equal(expected))
},
Entry("public files path", "/remote.php/dav/public-files/", true),
Entry("public files path without remote.php", "/remote.php/dav/public-files/", true),
Entry("token info path", "/ocs/v1.php/apps/files_sharing/api/v1/tokeninfo/unprotected", true),
Entry("token info path", "/ocs/v2.php/apps/files_sharing/api/v1/tokeninfo/unprotected", true),
Entry("capabilities", "/ocs/v1.php/cloud/capabilities", true),
)
})
var _ = Describe("Authenticating requests", Label("Authentication"), func() {
var (
authenticators []Authenticator
)
oc := oidcmocks.OIDCClient{}
oc.On("VerifyAccessToken", mock.Anything, mock.Anything).Return(
oidc.RegClaimsWithSID{
SessionID: "a-session-id",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Unix(1147483647, 0)),
},
}, jwt.MapClaims{
"sid": "a-session-id",
"exp": 1147483647,
},
nil,
)
ub := mocks.UserBackend{}
ub.On("Authenticate", mock.Anything, "testuser", "testpassword").Return(
&userv1beta1.User{
Id: &userv1beta1.UserId{
Idp: "IdpId",
OpaqueId: "OpaqueId",
},
Username: "testuser",
Mail: "testuser@example.com",
},
"",
nil,
)
ub.On("Authenticate", mock.Anything, mock.Anything, mock.Anything).Return(nil, "", backend.ErrAccountNotFound)
BeforeEach(func() {
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
logger := log.NewLogger()
authenticators = []Authenticator{
BasicAuthenticator{
Logger: logger,
UserProvider: &ub,
},
&OIDCAuthenticator{
OIDCIss: "http://idp.example.com",
Logger: logger,
oidcClient: &oc,
userInfoCache: store.NewMemoryStore(),
skipUserInfo: true,
},
PublicShareAuthenticator{
Logger: logger,
RevaGatewaySelector: pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return mockGatewayClient{
AuthenticateFunc: func(authType, clientID, clientSecret string) (string, rpcv1beta1.Code) {
if authType != "publicshares" {
return "", rpcv1beta1.Code_CODE_NOT_FOUND
}
if clientID == "sharetoken" && (clientSecret == "password|examples3cr3t" || clientSecret == "signature|examplesignature|exampleexpiration") {
return "exampletoken", rpcv1beta1.Code_CODE_OK
}
if clientID == "sharetoken" && clientSecret == "password|" {
return "otherexampletoken", rpcv1beta1.Code_CODE_OK
}
return "", rpcv1beta1.Code_CODE_NOT_FOUND
},
}
},
),
},
}
})
When("the public request must contains correct data", func() {
It("ensures the context oidc data when the Bearer authentication is successful", func() {
req := httptest.NewRequest("PROPFIND", "http://example.com/remote.php/dav/public-files/", http.NoBody)
req = req.WithContext(router.SetRoutingInfo(context.Background(), router.RoutingInfo{}))
req.Header.Set(_headerAuthorization, "Bearer jwt.token.sig")
handler := Authentication(authenticators,
EnableBasicAuth(true),
)
testHandler := handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expect(oidc.FromContext(r.Context())).To(Equal(map[string]any{
"sid": "a-session-id",
"exp": int64(1147483647),
}))
}))
rr := httptest.NewRecorder()
testHandler.ServeHTTP(rr, req)
Expect(rr).To(HaveHTTPStatus(http.StatusOK))
})
It("ensures the context oidc data when user the Basic authentication is successful", func() {
req := httptest.NewRequest("PROPFIND", "http://example.com/remote.php/dav/public-files/", http.NoBody)
req = req.WithContext(router.SetRoutingInfo(context.Background(), router.RoutingInfo{}))
req.SetBasicAuth("testuser", "testpassword")
handler := Authentication(authenticators,
EnableBasicAuth(true),
)
testHandler := handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expect(oidc.FromContext(r.Context())).To(Equal(map[string]any{
"email": "testuser@example.com",
"qsferauuid": "OpaqueId",
"iss": "IdpId",
"preferred_username": "testuser",
}))
}))
rr := httptest.NewRecorder()
testHandler.ServeHTTP(rr, req)
Expect(rr).To(HaveHTTPStatus(http.StatusOK))
})
It("ensures the x-access-token header when public-token URL parameter is set", func() {
req := httptest.NewRequest("PROPFIND", "http://example.com/dav/public-files/?public-token=sharetoken", http.NoBody)
req = req.WithContext(router.SetRoutingInfo(context.Background(), router.RoutingInfo{}))
handler := Authentication(authenticators,
EnableBasicAuth(true),
)
testHandler := handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expect(r.Header.Get(headerRevaAccessToken)).To(Equal("otherexampletoken"))
}))
rr := httptest.NewRecorder()
testHandler.ServeHTTP(rr, req)
Expect(rr).To(HaveHTTPStatus(http.StatusOK))
})
It("ensures the x-access-token header when public-token URL parameter and BasicAuth are set", func() {
req := httptest.NewRequest("PROPFIND", "http://example.com/dav/public-files/?public-token=sharetoken", http.NoBody)
req.SetBasicAuth("public", "examples3cr3t")
req = req.WithContext(router.SetRoutingInfo(context.Background(), router.RoutingInfo{}))
handler := Authentication(authenticators,
EnableBasicAuth(true),
)
testHandler := handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expect(r.Header.Get(headerRevaAccessToken)).To(Equal("exampletoken"))
}))
rr := httptest.NewRecorder()
testHandler.ServeHTTP(rr, req)
Expect(rr).To(HaveHTTPStatus(http.StatusOK))
})
It("ensures the x-access-token header when public-token BasicAuth is set", func() {
req := httptest.NewRequest("GET", "http://example.com/archiver", http.NoBody)
req.Header.Set("public-token", "sharetoken")
req = req.WithContext(router.SetRoutingInfo(context.Background(), router.RoutingInfo{}))
handler := Authentication(authenticators,
EnableBasicAuth(true),
)
testHandler := handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expect(r.Header.Get(headerRevaAccessToken)).To(Equal("otherexampletoken"))
}))
rr := httptest.NewRecorder()
testHandler.ServeHTTP(rr, req)
Expect(rr).To(HaveHTTPStatus(http.StatusOK))
})
})
})
@@ -0,0 +1,62 @@
package middleware
import (
"net/http"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/oidc"
"github.com/qsfera/server/services/proxy/pkg/user/backend"
)
// BasicAuthenticator is the authenticator responsible for HTTP Basic authentication.
type BasicAuthenticator struct {
Logger log.Logger
UserProvider backend.UserBackend
UserCS3Claim string
UserOIDCClaim string
}
// Authenticate implements the authenticator interface to authenticate requests via basic auth.
func (m BasicAuthenticator) Authenticate(r *http.Request) (*http.Request, bool) {
if isPublicPath(r.URL.Path) && isPublicWithShareToken(r) {
// The authentication of public path requests is handled by another authenticator.
// Since we can't guarantee the order of execution of the authenticators, we better
// implement an early return here for paths we can't authenticate in this authenticator.
return nil, false
}
login, password, ok := r.BasicAuth()
if !ok {
return nil, false
}
user, _, err := m.UserProvider.Authenticate(r.Context(), login, password)
if err != nil {
m.Logger.Error().
Err(err).
Str("authenticator", "basic").
Str("path", r.URL.Path).
Msg("failed to authenticate request")
return nil, false
}
// fake oidc claims
claims := map[string]any{
oidc.Iss: user.Id.Idp,
oidc.PreferredUsername: user.Username,
oidc.Email: user.Mail,
oidc.QsferaUUID: user.Id.OpaqueId,
}
if m.UserCS3Claim == "userid" {
// set the custom user claim only if users will be looked up by the userid on the CS3api
// OpaqueId contains the userid configured in STORAGE_LDAP_USER_SCHEMA_UID
claims[m.UserOIDCClaim] = user.Id.OpaqueId
}
m.Logger.Debug().
Str("authenticator", "basic").
Str("path", r.URL.Path).
Msg("successfully authenticated request")
return r.WithContext(oidc.NewContext(r.Context(), claims)), true
}
@@ -0,0 +1,67 @@
package middleware
import (
"net/http"
"net/http/httptest"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
. "github.com/onsi/ginkgo/v2"
"github.com/stretchr/testify/mock"
. "github.com/onsi/gomega"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/oidc"
"github.com/qsfera/server/services/proxy/pkg/user/backend"
"github.com/qsfera/server/services/proxy/pkg/user/backend/mocks"
)
var _ = Describe("Authenticating requests", Label("BasicAuthenticator"), func() {
var authenticator Authenticator
ub := mocks.UserBackend{}
ub.On("Authenticate", mock.Anything, "testuser", "testpassword").Return(
&userv1beta1.User{
Id: &userv1beta1.UserId{
Idp: "IdpId",
OpaqueId: "OpaqueId",
},
Username: "testuser",
Mail: "testuser@example.com",
},
"",
nil,
)
ub.On("Authenticate", mock.Anything, mock.Anything, mock.Anything).Return(nil, "", backend.ErrAccountNotFound)
BeforeEach(func() {
authenticator = BasicAuthenticator{
Logger: log.NewLogger(),
UserProvider: &ub,
}
})
When("the request contains correct data", func() {
It("should successfully authenticate", func() {
req := httptest.NewRequest(http.MethodGet, "http://example.com/example/path", http.NoBody)
req.SetBasicAuth("testuser", "testpassword")
req2, valid := authenticator.Authenticate(req)
Expect(valid).To(Equal(true))
Expect(req2).ToNot(BeNil())
})
It("adds claims to the request context", func() {
req := httptest.NewRequest(http.MethodGet, "http://example.com/example/path", http.NoBody)
req.SetBasicAuth("testuser", "testpassword")
req2, valid := authenticator.Authenticate(req)
Expect(valid).To(Equal(true))
claims := oidc.FromContext(req2.Context())
Expect(claims).ToNot(BeNil())
Expect(claims[oidc.Iss]).To(Equal("IdpId"))
Expect(claims[oidc.PreferredUsername]).To(Equal("testuser"))
Expect(claims[oidc.Email]).To(Equal("testuser@example.com"))
Expect(claims[oidc.QsferaUUID]).To(Equal("OpaqueId"))
})
})
})
@@ -0,0 +1,26 @@
package middleware
import (
"net/http"
"github.com/qsfera/server/pkg/log"
)
// ContextLogger is a middleware to use a logger associated with the request's
// context which includes general information of the request.
func ContextLogger(logger log.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := logger.With().
Str("remoteAddr", r.RemoteAddr).
Str(log.RequestIDString, r.Header.Get("X-Request-ID")).
Str("proto", r.Proto).
Str("method", r.Method).
Str("path", r.URL.Path).
Str("query", r.URL.RawQuery).
Str("fragment", r.URL.Fragment).
Logger().WithContext(r.Context())
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
@@ -0,0 +1,124 @@
package middleware
import (
"fmt"
"net/http"
"strconv"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/graph/pkg/errorcode"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/status"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"go.opentelemetry.io/otel/trace"
"google.golang.org/grpc/metadata"
)
// CreateHome provides a middleware which sends a CreateHome request to the reva gateway
func CreateHome(optionSetters ...Option) func(next http.Handler) http.Handler {
options := newOptions(optionSetters...)
logger := options.Logger
tracer := getTraceProvider(options).Tracer("proxy.middleware.create_home")
return func(next http.Handler) http.Handler {
return &createHome{
next: next,
logger: logger,
tracer: tracer,
revaGatewaySelector: options.RevaGatewaySelector,
roleQuotas: options.RoleQuotas,
}
}
}
type createHome struct {
next http.Handler
logger log.Logger
tracer trace.Tracer
revaGatewaySelector pool.Selectable[gateway.GatewayAPIClient]
roleQuotas map[string]uint64
}
func (m createHome) ServeHTTP(w http.ResponseWriter, req *http.Request) {
ctx, span := m.tracer.Start(req.Context(), fmt.Sprintf("%s %s", req.Method, req.URL.Path), trace.WithSpanKind(trace.SpanKindServer))
req = req.WithContext(ctx)
defer span.End()
if !m.shouldServe(req) {
span.End()
m.next.ServeHTTP(w, req)
return
}
token := req.Header.Get(revactx.TokenHeader)
// we need to pass the token to authenticate the CreateHome request.
//ctx := tokenpkg.ContextSetToken(r.Context(), token)
ctx = metadata.AppendToOutgoingContext(req.Context(), revactx.TokenHeader, token)
createHomeReq := &provider.CreateHomeRequest{}
u, ok := revactx.ContextGetUser(ctx)
if ok {
roleIDs, err := m.getUserRoles(u)
if err != nil {
m.logger.Error().Err(err).Str("userid", u.Id.OpaqueId).Msg("failed to get roles for user")
errorcode.GeneralException.Render(w, req, http.StatusInternalServerError, "Unauthorized")
return
}
if limit, hasLimit := m.checkRoleQuotaLimit(roleIDs); hasLimit {
createHomeReq.Opaque = utils.AppendPlainToOpaque(nil, "quota", strconv.FormatUint(limit, 10))
}
}
client, err := m.revaGatewaySelector.Next()
if err != nil {
m.logger.Err(err).Msg("error selecting next gateway client")
} else {
createHomeRes, err := client.CreateHome(ctx, createHomeReq)
if err != nil {
m.logger.Err(err).Msg("error calling CreateHome")
} else if createHomeRes.Status.Code != rpc.Code_CODE_OK {
err := status.NewErrorFromCode(createHomeRes.Status.Code, "gateway")
if createHomeRes.Status.Code != rpc.Code_CODE_ALREADY_EXISTS {
m.logger.Err(err).Msg("error when calling Createhome")
}
}
}
span.End()
m.next.ServeHTTP(w, req)
}
func (m createHome) shouldServe(req *http.Request) bool {
return req.Header.Get(revactx.TokenHeader) != ""
}
func (m createHome) getUserRoles(user *userv1beta1.User) ([]string, error) {
var roleIDs []string
if err := utils.ReadJSONFromOpaque(user.Opaque, "roles", &roleIDs); err != nil {
return nil, err
}
tmp := make(map[string]struct{})
for _, id := range roleIDs {
tmp[id] = struct{}{}
}
dedup := make([]string, 0, len(tmp))
for k := range tmp {
dedup = append(dedup, k)
}
return dedup, nil
}
func (m createHome) checkRoleQuotaLimit(roleIDs []string) (uint64, bool) {
if len(roleIDs) == 0 {
return 0, false
}
id := roleIDs[0] // At the moment a user can only have one role.
quota, ok := m.roleQuotas[id]
return quota, ok
}
@@ -0,0 +1,19 @@
package middleware
import (
"fmt"
"net/http"
)
// HTTPSRedirect redirects insecure requests to https
func HTTPSRedirect(next http.Handler) http.Handler {
return http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
proto := req.Header.Get("x-forwarded-proto")
if proto == "http" || proto == "HTTP" {
http.Redirect(res, req, fmt.Sprintf("https://%s%s", req.Host, req.URL), http.StatusPermanentRedirect)
return
}
next.ServeHTTP(res, req)
})
}
@@ -0,0 +1,28 @@
package middleware
import (
"net/http"
"time"
"github.com/go-chi/chi/v5/middleware"
"github.com/qsfera/server/services/proxy/pkg/metrics"
"github.com/prometheus/client_golang/prometheus"
)
// Instrumenter provides a middleware to create metrics
func Instrumenter(m metrics.Metrics) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
m.Requests.With(prometheus.Labels{"method": r.Method}).Inc()
next.ServeHTTP(ww, r)
m.Duration.With(prometheus.Labels{"method": r.Method}).Observe(float64(time.Since(start).Seconds()))
if ww.Status() >= 500 {
m.Errors.With(prometheus.Labels{"method": r.Method}).Inc()
}
})
}
}
@@ -0,0 +1,13 @@
package middleware_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestMiddleware(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Middleware Suite")
}
@@ -0,0 +1,223 @@
package middleware
import (
"context"
"encoding/base64"
"net"
"net/http"
"strings"
"time"
"github.com/pkg/errors"
"github.com/vmihailenco/msgpack/v5"
"go-micro.dev/v4/store"
"golang.org/x/crypto/sha3"
"golang.org/x/oauth2"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/oidc"
"github.com/qsfera/server/services/proxy/pkg/staticroutes"
)
const (
_headerAuthorization = "Authorization"
_bearerPrefix = "Bearer "
)
// NewOIDCAuthenticator returns a ready to use authenticator which can handle OIDC authentication.
func NewOIDCAuthenticator(opts ...Option) *OIDCAuthenticator {
options := newOptions(opts...)
return &OIDCAuthenticator{
Logger: options.Logger,
userInfoCache: options.UserInfoCache,
DefaultTokenCacheTTL: options.DefaultAccessTokenTTL,
HTTPClient: options.HTTPClient,
OIDCIss: options.OIDCIss,
oidcClient: options.OIDCClient,
AccessTokenVerifyMethod: options.AccessTokenVerifyMethod,
skipUserInfo: options.SkipUserInfo,
TimeFunc: time.Now,
}
}
// OIDCAuthenticator is an authenticator responsible for OIDC authentication.
type OIDCAuthenticator struct {
Logger log.Logger
HTTPClient *http.Client
OIDCIss string
userInfoCache store.Store
DefaultTokenCacheTTL time.Duration
oidcClient oidc.OIDCClient
AccessTokenVerifyMethod string
skipUserInfo bool
TimeFunc func() time.Time
}
func (m *OIDCAuthenticator) getClaims(token string, req *http.Request) (map[string]any, bool, error) {
var claims map[string]any
// use a 64 bytes long hash to have 256-bit collision resistance.
hash := make([]byte, 64)
sha3.ShakeSum256(hash, []byte(token))
encodedHash := base64.URLEncoding.EncodeToString(hash)
record, err := m.userInfoCache.Read(encodedHash)
if err != nil && err != store.ErrNotFound {
m.Logger.Error().Err(err).Msg("could not read from userinfo cache")
}
if len(record) > 0 {
if err = msgpack.Unmarshal(record[0].Value, &claims); err == nil {
m.Logger.Debug().Interface("claims", claims).Msg("cache hit for userinfo")
if verifyExpiresAt(claims, m.TimeFunc()) {
return claims, false, nil
}
m.Logger.Debug().Msg("cached userinfo claims expired, ignoring cache")
} else {
m.Logger.Error().Err(err).Msg("failed to unmarshal cached userinfo, ignoring cache")
}
}
aClaims, claims, err := m.oidcClient.VerifyAccessToken(req.Context(), token)
if err != nil {
return nil, false, errors.Wrap(err, "failed to verify access token")
}
if !m.skipUserInfo {
oauth2Token := &oauth2.Token{
AccessToken: token,
}
userInfo, err := m.oidcClient.UserInfo(
context.WithValue(req.Context(), oauth2.HTTPClient, m.HTTPClient),
oauth2.StaticTokenSource(oauth2Token),
)
if err != nil {
return nil, false, errors.Wrap(err, "failed to get userinfo")
}
if err := userInfo.Claims(&claims); err != nil {
return nil, false, errors.Wrap(err, "failed to unmarshal userinfo claims")
}
}
expiration := m.extractExpiration(aClaims)
// always set an exp claim
claims["exp"] = expiration.Unix()
go func() {
if d, err := msgpack.Marshal(claims); err != nil {
m.Logger.Error().Err(err).Msg("failed to marshal claims for userinfo cache")
} else {
err = m.userInfoCache.Write(&store.Record{
Key: encodedHash,
Value: d,
Expiry: time.Until(expiration),
})
if err != nil {
m.Logger.Error().Err(err).Msg("failed to write to userinfo cache")
}
// fail if creating the storage key fails,
// it means there is no subject and no session.
//
// ok: {key: ".sessionId"}
// ok: {key: "subject."}
// ok: {key: "subject.sessionId"}
// fail: {key: "."}
subjectSessionKey, err := staticroutes.NewRecordKey(aClaims.Subject, aClaims.SessionID)
if err != nil {
m.Logger.Error().Err(err).Msg("failed to build subject.session")
return
}
if err := m.userInfoCache.Write(&store.Record{
Key: subjectSessionKey,
Value: []byte(encodedHash),
Expiry: time.Until(expiration),
}); err != nil {
m.Logger.Error().Err(err).Msg("failed to write session lookup cache")
}
}
}()
// If we get here this was a new login (or a renewal of the token)
// add a flag about that to the claims, to be able to distinguish
// it in the accountresolver middleware
m.Logger.Debug().Interface("claims", claims).Msg("extracted claims")
return claims, true, nil
}
// extractExpiration tries to extract the expriration time from the access token
// If the access token does not have an exp claim it will fallback to the configured
// default expiration
func (m OIDCAuthenticator) extractExpiration(aClaims oidc.RegClaimsWithSID) time.Time {
defaultExpiration := time.Now().Add(m.DefaultTokenCacheTTL)
if aClaims.ExpiresAt != nil {
m.Logger.Debug().Str("exp", aClaims.ExpiresAt.String()).Msg("Expiration Time from access_token")
return aClaims.ExpiresAt.Time
}
return defaultExpiration
}
func verifyExpiresAt(claims map[string]any, cmp time.Time) bool {
var expiry time.Time
switch v := claims["exp"].(type) {
case nil:
return false
case int64:
expiry = time.Unix(v, 0)
case uint32:
expiry = time.Unix(int64(v), 0)
}
return cmp.Before(expiry)
}
func (m OIDCAuthenticator) shouldServe(req *http.Request) bool {
if m.OIDCIss == "" {
return false
}
header := req.Header.Get(_headerAuthorization)
return strings.HasPrefix(header, _bearerPrefix)
}
// Authenticate implements the authenticator interface to authenticate requests via oidc auth.
func (m *OIDCAuthenticator) Authenticate(r *http.Request) (*http.Request, bool) {
// there is no bearer token on the request,
if !m.shouldServe(r) {
// The authentication of public path requests is handled by another authenticator.
// Since we can't guarantee the order of execution of the authenticators, we better
// implement an early return here for paths we can't authenticate in this authenticator.
return nil, false
}
token := strings.TrimPrefix(r.Header.Get(_headerAuthorization), _bearerPrefix)
if token == "" {
return nil, false
}
claims, newSession, err := m.getClaims(token, r)
if err != nil {
host, port, _ := net.SplitHostPort(r.RemoteAddr)
m.Logger.Error().
Err(err).
Str("authenticator", "oidc").
Str("path", r.URL.Path).
Str("user_agent", r.UserAgent()).
Str("client.address", r.Header.Get("X-Forwarded-For")).
Str("network.peer.address", host).
Str("network.peer.port", port).
Msg("failed to authenticate the request")
return nil, false
}
m.Logger.Debug().
Str("authenticator", "oidc").
Str("path", r.URL.Path).
Msg("successfully authenticated request")
ctx := r.Context()
if newSession {
ctx = oidc.NewContextSessionFlag(ctx, true)
}
return r.WithContext(oidc.NewContext(ctx, claims)), true
}
@@ -0,0 +1,101 @@
package middleware
import (
"net/http"
"net/http/httptest"
"time"
"github.com/golang-jwt/jwt/v5"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/oidc"
oidcmocks "github.com/qsfera/server/pkg/oidc/mocks"
"github.com/stretchr/testify/mock"
"go-micro.dev/v4/store"
)
var _ = Describe("Authenticating requests", Label("OIDCAuthenticator"), func() {
var authenticator Authenticator
oc := oidcmocks.OIDCClient{}
oc.On("VerifyAccessToken", mock.Anything, mock.Anything).Return(
oidc.RegClaimsWithSID{
SessionID: "a-session-id",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Unix(1147483647, 0)),
},
}, jwt.MapClaims{
"sid": "a-session-id",
"exp": 1147483647,
},
nil,
)
/*
// to test with skipUserInfo: true, we need to also use an interface so we can mock the UserInfo.Claim call
oc.On("UserInfo", mock.Anything, mock.Anything).Return(
&oidc.UserInfo{
Subject: "my-sub",
EmailVerified: true,
Email: "test@example.org",
},
nil,
)
*/
BeforeEach(func() {
authenticator = &OIDCAuthenticator{
OIDCIss: "http://idp.example.com",
Logger: log.NewLogger(),
oidcClient: &oc,
userInfoCache: store.NewMemoryStore(),
skipUserInfo: true,
}
})
When("the request contains correct data", func() {
It("should successfully authenticate", func() {
req := httptest.NewRequest(http.MethodGet, "http://example.com/example/path", http.NoBody)
req.Header.Set(_headerAuthorization, "Bearer jwt.token.sig")
req2, valid := authenticator.Authenticate(req)
Expect(valid).To(Equal(true))
Expect(req2).ToNot(BeNil())
})
It("should successfully authenticate", func() {
req := httptest.NewRequest(http.MethodGet, "http://example.com/dav/public-files", http.NoBody)
req.Header.Set(_headerAuthorization, "Bearer jwt.token.sig")
req2, valid := authenticator.Authenticate(req)
Expect(valid).To(Equal(true))
Expect(req2).ToNot(BeNil())
})
It("should skip authenticate if the header ShareToken is set", func() {
req := httptest.NewRequest(http.MethodGet, "http://example.com/dav/public-files/", http.NoBody)
req.Header.Set(_headerAuthorization, "Bearer jwt.token.sig")
req.Header.Set(headerShareToken, "sharetoken")
req2, valid := authenticator.Authenticate(req)
// TODO Should the authentication of public path requests is handled by another authenticator?
//Expect(valid).To(Equal(false))
//Expect(req2).To(BeNil())
Expect(valid).To(Equal(true))
Expect(req2).ToNot(BeNil())
})
It("should skip authenticate if the 'public-token' is set", func() {
req := httptest.NewRequest(http.MethodGet, "http://example.com/dav/public-files/?public-token=sharetoken", http.NoBody)
req.Header.Set(_headerAuthorization, "Bearer jwt.token.sig")
req2, valid := authenticator.Authenticate(req)
// TODO Should the authentication of public path requests is handled by another authenticator?
//Expect(valid).To(Equal(false))
//Expect(req2).To(BeNil())
Expect(valid).To(Equal(true))
Expect(req2).ToNot(BeNil())
})
})
})
@@ -0,0 +1,287 @@
package middleware
import (
"net/http"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/oidc"
policiessvc "github.com/qsfera/server/protogen/gen/qsfera/services/policies/v0"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/proxy/pkg/config"
"github.com/qsfera/server/services/proxy/pkg/user/backend"
"github.com/qsfera/server/services/proxy/pkg/userroles"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"go-micro.dev/v4/store"
"go.opentelemetry.io/otel/trace"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
// Logger to use for logging, must be set
Logger log.Logger
// PolicySelectorConfig for using the policy selector
PolicySelector config.PolicySelector
// HTTPClient to use for communication with the oidcAuth provider
HTTPClient *http.Client
// UserProvider backend to use for resolving User
UserProvider backend.UserBackend
// UserRoleAssigner to user for assign a users default role
UserRoleAssigner userroles.UserRoleAssigner
// SettingsRoleService for the roles API in settings
SettingsRoleService settingssvc.RoleService
// PoliciesProviderService for policy evaluation
PoliciesProviderService policiessvc.PoliciesProviderService
// OIDCClient to fetch user info and verify tokens, must be set for the oidc_auth middleware
OIDCClient oidc.OIDCClient
// OIDCIss is the oidcAuth-issuer
OIDCIss string
// RevaGatewaySelector to send requests to the reva gateway
RevaGatewaySelector pool.Selectable[gateway.GatewayAPIClient]
// PreSignedURLConfig to configure the middleware
PreSignedURLConfig config.PreSignedURL
// UserOIDCClaim to read from the oidc claims
UserOIDCClaim string
// UserCS3Claim to use when looking up a user in the CS3 API
UserCS3Claim string
// TenantOIDCClaim is a JMESPath expression to extract the tenant ID from the OIDC claims.
// When set, the extracted value is verified against the tenant ID on the resolved user.
TenantOIDCClaim string
// AutoprovisionAccounts when an accountResolver does not exist.
AutoprovisionAccounts bool
// EnableBasicAuth to allow basic auth
EnableBasicAuth bool
// DefaultAccessTokenTTL is used to calculate the expiration when an access token has no expiration set
DefaultAccessTokenTTL time.Duration
// UserInfoCache sets the access token cache store
UserInfoCache store.Store
// CredentialsByUserAgent sets the auth challenges on a per user-agent basis
CredentialsByUserAgent map[string]string
// AccessTokenVerifyMethod configures how access_tokens should be verified but the oidc_auth middleware.
// Possible values currently: "jwt" and "none"
AccessTokenVerifyMethod string
// JWKS sets the options for fetching the JWKS from the IDP
JWKS config.JWKS
// RoleQuotas hold userid:quota mappings. These will be used when provisioning new users.
// The users will get as much quota as is set for their role.
RoleQuotas map[string]uint64
// TraceProvider sets the tracing provider.
TraceProvider trace.TracerProvider
// SkipUserInfo prevents the oidc middleware from querying the userinfo endpoint and read any claims directly from the access token instead
SkipUserInfo bool
// MultiTenantEnabled causes the account resolve middleware to reject users that don't have a tenant id assigned
MultiTenantEnabled bool
// TenantIDMappingEnabled causes the account resolver to resolve the internal tenant ID from the external
// tenant ID in the OIDC claims via the gateway's TenantAPI before comparing it to the user's stored tenant ID.
TenantIDMappingEnabled bool
// ServiceAccount holds credentials used to authenticate internal service calls (e.g. TenantAPI lookups).
ServiceAccount config.ServiceAccount
EventsPublisher events.Publisher
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Logger provides a function to set the logger option.
func Logger(l log.Logger) Option {
return func(o *Options) {
o.Logger = l
}
}
// PolicySelectorConfig provides a function to set the policy selector config option.
func PolicySelectorConfig(cfg config.PolicySelector) Option {
return func(o *Options) {
o.PolicySelector = cfg
}
}
// HTTPClient provides a function to set the http client config option.
func HTTPClient(c *http.Client) Option {
return func(o *Options) {
o.HTTPClient = c
}
}
// SettingsRoleService provides a function to set the role service option.
func SettingsRoleService(rc settingssvc.RoleService) Option {
return func(o *Options) {
o.SettingsRoleService = rc
}
}
// PoliciesProviderService provides a function to set the policies provider option.
func PoliciesProviderService(pps policiessvc.PoliciesProviderService) Option {
return func(o *Options) {
o.PoliciesProviderService = pps
}
}
// OIDCClient provides a function to set the oidc client option.
func OIDCClient(val oidc.OIDCClient) Option {
return func(o *Options) {
o.OIDCClient = val
}
}
// OIDCIss sets the oidcAuth issuer url
func OIDCIss(iss string) Option {
return func(o *Options) {
o.OIDCIss = iss
}
}
// CredentialsByUserAgent sets UserAgentChallenges.
func CredentialsByUserAgent(v map[string]string) Option {
return func(o *Options) {
o.CredentialsByUserAgent = v
}
}
// WithRevaGatewaySelector provides a function to set the reva gateway service selector option.
func WithRevaGatewaySelector(val pool.Selectable[gateway.GatewayAPIClient]) Option {
return func(o *Options) {
o.RevaGatewaySelector = val
}
}
// PreSignedURLConfig provides a function to set the PreSignedURL config
func PreSignedURLConfig(cfg config.PreSignedURL) Option {
return func(o *Options) {
o.PreSignedURLConfig = cfg
}
}
// UserOIDCClaim provides a function to set the UserClaim config
func UserOIDCClaim(val string) Option {
return func(o *Options) {
o.UserOIDCClaim = val
}
}
// UserCS3Claim provides a function to set the UserClaimType config
func UserCS3Claim(val string) Option {
return func(o *Options) {
o.UserCS3Claim = val
}
}
// TenantOIDCClaim provides a function to set the TenantOIDCClaim config
func TenantOIDCClaim(val string) Option {
return func(o *Options) {
o.TenantOIDCClaim = val
}
}
// AutoprovisionAccounts provides a function to set the AutoprovisionAccounts config
func AutoprovisionAccounts(val bool) Option {
return func(o *Options) {
o.AutoprovisionAccounts = val
}
}
// EnableBasicAuth provides a function to set the EnableBasicAuth config
func EnableBasicAuth(enableBasicAuth bool) Option {
return func(o *Options) {
o.EnableBasicAuth = enableBasicAuth
}
}
// DefaultAccessTokenTTL provides a function to set the DefaultAccessTokenTTL
func DefaultAccessTokenTTL(ttl time.Duration) Option {
return func(o *Options) {
o.DefaultAccessTokenTTL = ttl
}
}
// UserInfoCache provides a function to set the UserInfoCache
func UserInfoCache(val store.Store) Option {
return func(o *Options) {
o.UserInfoCache = val
}
}
// UserProvider sets the accounts user provider
func UserProvider(up backend.UserBackend) Option {
return func(o *Options) {
o.UserProvider = up
}
}
// UserRoleAssigner sets the mechanism for assigning the default user roles
func UserRoleAssigner(ra userroles.UserRoleAssigner) Option {
return func(o *Options) {
o.UserRoleAssigner = ra
}
}
// AccessTokenVerifyMethod set the mechanism for access token verification
func AccessTokenVerifyMethod(method string) Option {
return func(o *Options) {
o.AccessTokenVerifyMethod = method
}
}
// RoleQuotas sets the role quota mapping setting
func RoleQuotas(roleQuotas map[string]uint64) Option {
return func(o *Options) {
o.RoleQuotas = roleQuotas
}
}
// TraceProvider sets the tracing provider.
func TraceProvider(tp trace.TracerProvider) Option {
return func(o *Options) {
o.TraceProvider = tp
}
}
// SkipUserInfo sets the skipUserInfo flag.
func SkipUserInfo(val bool) Option {
return func(o *Options) {
o.SkipUserInfo = val
}
}
// MultiTenantEnabled sets the MultiTenantEnabled flag.
func MultiTenantEnabled(val bool) Option {
return func(o *Options) {
o.MultiTenantEnabled = val
}
}
// ServiceAccount sets the service account credentials used for authenticated internal calls.
func ServiceAccount(sa config.ServiceAccount) Option {
return func(o *Options) {
o.ServiceAccount = sa
}
}
// TenantIDMappingEnabled sets the TenantIDMappingEnabled flag.
// When true, the account resolver resolves the internal tenant ID from the external tenant ID
// provided in the OIDC claims by calling the gateway's TenantAPI, instead of comparing directly.
func TenantIDMappingEnabled(val bool) Option {
return func(o *Options) {
o.TenantIDMappingEnabled = val
}
}
// EventsPublisher sets the events publisher.
func EventsPublisher(ep events.Publisher) Option {
return func(o *Options) {
o.EventsPublisher = ep
}
}
@@ -0,0 +1,175 @@
package middleware
import (
"fmt"
"net/http"
"path"
"path/filepath"
"strings"
"time"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/render"
tusd "github.com/tus/tusd/v2/pkg/handler"
"go.opentelemetry.io/otel/trace"
"google.golang.org/grpc/metadata"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/reva/v2/pkg/utils"
pMessage "github.com/qsfera/server/protogen/gen/qsfera/messages/policies/v0"
pService "github.com/qsfera/server/protogen/gen/qsfera/services/policies/v0"
"github.com/qsfera/server/services/webdav/pkg/net"
)
type (
// RequestDenied struct for OdataErrorMain
RequestDenied struct {
Error RequestDeniedError `json:"error"`
}
// RequestDeniedError struct for RequestDenied
RequestDeniedError struct {
Code string `json:"code"`
Message string `json:"message"`
// The structure of this object is service-specific
Innererror map[string]any `json:"innererror,omitempty"`
}
)
const DeniedMessage = "Operation denied due to security policies"
// Policies verifies if a request is granted or not.
func Policies(qs string, opts ...Option) func(next http.Handler) http.Handler {
options := newOptions(opts...)
logger := options.Logger
tracer := getTraceProvider(options).Tracer("proxy.middleware.policies")
gatewaySelector := options.RevaGatewaySelector
policiesProviderClient := options.PoliciesProviderService
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), fmt.Sprintf("%s %s", r.Method, r.URL.Path), trace.WithSpanKind(trace.SpanKindServer))
r = r.WithContext(ctx)
defer span.End()
if qs == "" {
span.End()
next.ServeHTTP(w, r)
return
}
req := &pService.EvaluateRequest{
Query: qs,
Environment: &pMessage.Environment{
Request: &pMessage.Request{
Method: r.Method,
Path: r.URL.Path,
},
Stage: pMessage.Stage_STAGE_HTTP,
},
}
resource := &pMessage.Resource{}
// tus
meta := tusd.ParseMetadataHeader(r.Header.Get(net.HeaderUploadMetadata))
resource.Name = meta["filename"]
// name is part of the request path
if resource.Name == "" && filepath.Ext(r.URL.Path) != "" {
resource.Name = filepath.Base(r.URL.Path)
}
// no resource info in path, stat the resource and try to obtain the file information.
// this should only be used as last bastion, every request goes through the proxy and doing stats is expensive!
// needed for:
// - if a single resource is shared -> the url only contains the resourceID (spaceRef)
if resource.Name == "" && filepath.Ext(r.URL.Path) == "" && r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/remote.php/dav/spaces") {
client, err := gatewaySelector.Next()
if err != nil {
logger.Err(err).Msg("error selecting next gateway client")
RenderError(w, r, req, http.StatusForbidden, DeniedMessage)
return
}
resourceID, err := storagespace.ParseID(strings.TrimPrefix(r.URL.Path, "/remote.php/dav/spaces/"))
if err != nil {
logger.Debug().Err(err).Msg("error parsing the resourceId")
RenderError(w, r, req, http.StatusForbidden, DeniedMessage)
return
}
if resourceID.StorageId == "" && resourceID.SpaceId == utils.ShareStorageSpaceID {
resourceID.StorageId = utils.ShareStorageProviderID
}
token := r.Header.Get(revactx.TokenHeader)
ctx := metadata.AppendToOutgoingContext(r.Context(), revactx.TokenHeader, token)
sRes, err := client.Stat(ctx, &provider.StatRequest{
Ref: &provider.Reference{
ResourceId: &resourceID,
},
})
resource.Name = sRes.GetInfo().GetName()
}
req.Environment.Resource = resource
if user, ok := revactx.ContextGetUser(r.Context()); ok {
req.Environment.User = &pMessage.User{
Id: &pMessage.User_ID{
OpaqueId: user.GetId().GetOpaqueId(),
},
Username: user.GetUsername(),
Mail: user.GetMail(),
DisplayName: user.GetDisplayName(),
Groups: user.GetGroups(),
}
}
rsp, err := policiesProviderClient.Evaluate(r.Context(), req)
if err != nil {
logger.Err(err).Msg("error evaluating request")
w.WriteHeader(http.StatusInternalServerError)
return
}
if !rsp.Result {
RenderError(w, r, req, http.StatusForbidden, DeniedMessage)
return
}
span.End()
next.ServeHTTP(w, r)
})
}
}
// RenderError writes a Policies ErrorObject to the response writer
func RenderError(w http.ResponseWriter, r *http.Request, evaluateReq *pService.EvaluateRequest, status int, msg string) {
filename := evaluateReq.Environment.GetResource().GetName()
if filename == "" {
filename = path.Base(evaluateReq.Environment.GetRequest().GetPath())
}
innererror := map[string]any{
"date": time.Now().UTC().Format(time.RFC3339),
}
innererror["request-id"] = middleware.GetReqID(r.Context())
innererror["method"] = evaluateReq.Environment.GetRequest().GetMethod()
innererror["filename"] = filename
innererror["path"] = evaluateReq.Environment.GetRequest().GetPath()
resp := &RequestDenied{
Error: RequestDeniedError{
Code: "deniedByPolicy",
Message: msg,
Innererror: innererror,
},
}
render.Status(r, status)
render.JSON(w, r, resp)
}
@@ -0,0 +1,171 @@
package middleware_test
import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
. "github.com/onsi/gomega"
pMessage "github.com/qsfera/server/protogen/gen/qsfera/messages/policies/v0"
policiesPG "github.com/qsfera/server/protogen/gen/qsfera/services/policies/v0"
"github.com/qsfera/server/protogen/gen/qsfera/services/policies/v0/mocks"
"github.com/qsfera/server/services/proxy/pkg/middleware"
"github.com/qsfera/server/services/webdav/pkg/net"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/stretchr/testify/mock"
"go-micro.dev/v4/client"
"google.golang.org/grpc"
)
func TestPolicies_NoQuery_PassThrough(t *testing.T) {
var g = NewWithT(t)
policiesMiddleware, _, _ := prepare("")
responseRecorder := httptest.NewRecorder()
policiesMiddleware.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/policies", nil))
g.Expect(responseRecorder.Code).To(Equal(http.StatusOK))
}
func TestPolicies_ErrorsOnEvaluationError(t *testing.T) {
var g = NewWithT(t)
policiesMiddleware, policiesProviderService, _ := prepare("any")
policiesProviderService.On("Evaluate", mock.Anything, mock.Anything).Return(
nil,
errors.New("any"),
)
responseRecorder := httptest.NewRecorder()
policiesMiddleware.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/policies", nil))
g.Expect(responseRecorder.Code).To(Equal(http.StatusInternalServerError))
}
func TestPolicies_ErrorsOnDeny(t *testing.T) {
var g = NewWithT(t)
policiesMiddleware, policiesProviderService, _ := prepare("any")
policiesProviderService.On("Evaluate", mock.Anything, mock.Anything).Return(
&policiesPG.EvaluateResponse{},
nil,
)
responseRecorder := httptest.NewRecorder()
policiesMiddleware.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/policies", nil))
result := responseRecorder.Result()
defer func() {
g.Expect(result.Body.Close()).ToNot(HaveOccurred())
}()
data, err := io.ReadAll(result.Body)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(data).To(ContainSubstring(middleware.DeniedMessage))
g.Expect(responseRecorder.Code).To(Equal(http.StatusForbidden))
}
func TestPolicies_EvaluationEnvironment_HTTPStage(t *testing.T) {
var g = NewWithT(t)
policiesMiddleware, policiesProviderService, _ := prepare("any")
policiesProviderService.On("Evaluate", mock.Anything, mock.Anything, mock.Anything).Return(
func(_ context.Context, in *policiesPG.EvaluateRequest, _ ...client.CallOption) (*policiesPG.EvaluateResponse, error) {
g.Expect(in.Environment.Stage).To(Equal(pMessage.Stage_STAGE_HTTP))
return &policiesPG.EvaluateResponse{Result: false}, nil
},
)
responseRecorder := httptest.NewRecorder()
policiesMiddleware.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/policies", nil))
}
func TestPolicies_EvaluationEnvironment_Request(t *testing.T) {
var g = NewWithT(t)
policiesMiddleware, policiesProviderService, _ := prepare("any")
policiesProviderService.On("Evaluate", mock.Anything, mock.Anything, mock.Anything).Return(
func(_ context.Context, in *policiesPG.EvaluateRequest, _ ...client.CallOption) (*policiesPG.EvaluateResponse, error) {
g.Expect(in.Environment.Request.Method).To(Equal(http.MethodDelete))
g.Expect(in.Environment.Request.Path).To(Equal("/whatever"))
return &policiesPG.EvaluateResponse{Result: false}, nil
},
)
responseRecorder := httptest.NewRecorder()
policiesMiddleware.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodDelete, "/whatever", nil))
}
func TestPolicies_EvaluationEnvironment_Resource(t *testing.T) {
var g = NewWithT(t)
policiesMiddleware, policiesProviderService, _ := prepare("any")
// tus metadata
{
responseRecorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/remote.php/dav/spaces", nil)
request.Header.Set(net.HeaderUploadMetadata, fmt.Sprintf("filename %v", base64.StdEncoding.EncodeToString([]byte("tus-file-name.png"))))
policiesProviderService.On("Evaluate", mock.Anything, mock.Anything, mock.Anything).Return(
func(_ context.Context, in *policiesPG.EvaluateRequest, _ ...client.CallOption) (*policiesPG.EvaluateResponse, error) {
g.Expect(in.Environment.Resource.Name).To(Equal("tus-file-name.png"))
return &policiesPG.EvaluateResponse{Result: false}, nil
},
).Once()
policiesMiddleware.ServeHTTP(responseRecorder, request)
}
// url path
{
responseRecorder := httptest.NewRecorder()
policiesProviderService.On("Evaluate", mock.Anything, mock.Anything, mock.Anything).Return(
func(_ context.Context, in *policiesPG.EvaluateRequest, _ ...client.CallOption) (*policiesPG.EvaluateResponse, error) {
g.Expect(in.Environment.Resource.Name).To(Equal("simple-file-name.png"))
return &policiesPG.EvaluateResponse{Result: false}, nil
},
).Once()
policiesMiddleware.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodPut, "/remote.php/dav/spaces/simple-file-name.png", nil))
}
}
func prepare(q string) (http.Handler, *mocks.PoliciesProviderService, *cs3mocks.GatewayAPIClient) {
// mocked gatewaySelector
gatewayClient := &cs3mocks.GatewayAPIClient{}
gatewaySelector := pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return gatewayClient
},
)
defer pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
// mocked policiesProviderService
policiesProviderService := &mocks.PoliciesProviderService{}
// spin up middleware
policiesMiddleware := middleware.Policies(
q,
middleware.WithRevaGatewaySelector(gatewaySelector),
middleware.PoliciesProviderService(policiesProviderService),
)(mockHandler{})
return policiesMiddleware, policiesProviderService, gatewayClient
}
type mockHandler struct{}
func (m mockHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {}
@@ -0,0 +1,133 @@
package middleware
import (
"net/http"
"strings"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
"github.com/qsfera/server/pkg/log"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
)
const (
headerRevaAccessToken = revactx.TokenHeader
headerShareToken = "public-token"
basicAuthPasswordPrefix = "password|"
authenticationType = "publicshares"
_paramSignature = "signature"
_paramExpiration = "expiration"
)
// PublicShareAuthenticator is the authenticator which can authenticate public share requests.
// It will add the share owner into the request context.
type PublicShareAuthenticator struct {
Logger log.Logger
RevaGatewaySelector pool.Selectable[gateway.GatewayAPIClient]
}
// The archiver is able to create archives from public shares in which case it needs to use the
// PublicShareAuthenticator. It might however also be called using "normal" authentication or
// using signed url, which are handled by other middleware. For this reason we can't just
// handle `/archiver` with the `isPublicPath()` check.
func isPublicShareArchive(r *http.Request) bool {
if strings.HasPrefix(r.URL.Path, "/archiver") {
if r.URL.Query().Get(headerShareToken) != "" || r.Header.Get(headerShareToken) != "" {
return true
}
}
return false
}
// The app open requests can be made in public share contexts. For that the PublicShareAuthenticator needs to
// augment the request context.
// The app open requests can also be made in authenticated context. In these cases the PublicShareAuthenticator
// needs to ignore the request.
func isPublicShareAppOpen(r *http.Request) bool {
return (strings.HasPrefix(r.URL.Path, "/app/open") || strings.HasPrefix(r.URL.Path, "/app/new")) &&
(r.URL.Query().Get(headerShareToken) != "" || r.Header.Get(headerShareToken) != "")
}
// The public-files requests can also be made in authenticated context. In these cases the OIDCAuthenticator and
// the BasicAuthenticator needs to ignore the request when the headerShareToken exist.
func isPublicWithShareToken(r *http.Request) bool {
return (strings.HasPrefix(r.URL.Path, "/dav/public-files") || strings.HasPrefix(r.URL.Path, "/remote.php/dav/public-files")) &&
(r.URL.Query().Get(headerShareToken) != "" || r.Header.Get(headerShareToken) != "")
}
// Authenticate implements the authenticator interface to authenticate requests via public share auth.
func (a PublicShareAuthenticator) Authenticate(r *http.Request) (*http.Request, bool) {
if !isPublicPath(r.URL.Path) && !isPublicShareArchive(r) && !isPublicShareAppOpen(r) {
return nil, false
}
query := r.URL.Query()
shareToken := r.Header.Get(headerShareToken)
if shareToken == "" {
shareToken = query.Get(headerShareToken)
}
if shareToken == "" {
// If the share token is not set then we don't need to inject the user to
// the request context so we can just continue with the request.
return r, true
}
var sharePassword string
if signature := query.Get(_paramSignature); signature != "" {
expiration := query.Get(_paramExpiration)
if expiration == "" {
a.Logger.Warn().Str("signature", signature).Msg("cannot do signature auth without the expiration")
return nil, false
}
sharePassword = strings.Join([]string{"signature", signature, expiration}, "|")
} else {
// We can ignore the username since it is always set to "public" in public shares.
_, password, ok := r.BasicAuth()
sharePassword = basicAuthPasswordPrefix
if ok {
sharePassword += password
}
}
client, err := a.RevaGatewaySelector.Next()
if err != nil {
a.Logger.Error().
Err(err).
Str("authenticator", "public_share").
Str("public_share_token", shareToken).
Str("path", r.URL.Path).
Msg("could not select next gateway client")
return nil, false
}
authResp, err := client.Authenticate(r.Context(), &gateway.AuthenticateRequest{
Type: authenticationType,
ClientId: shareToken,
ClientSecret: sharePassword,
})
if err != nil {
a.Logger.Error().
Err(err).
Str("authenticator", "public_share").
Str("public_share_token", shareToken).
Str("path", r.URL.Path).
Msg("failed to authenticate request")
return nil, false
}
r.Header.Add(headerRevaAccessToken, authResp.Token)
trace.SpanFromContext(r.Context()).SetAttributes(attribute.String("enduser.id", "public"))
a.Logger.Debug().
Str("authenticator", "public_share").
Str("path", r.URL.Path).
Msg("successfully authenticated request")
return r, true
}
@@ -0,0 +1,114 @@
package middleware
import (
"context"
"net/http"
"net/http/httptest"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/qsfera/server/pkg/log"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"google.golang.org/grpc"
)
var _ = Describe("Authenticating requests", Label("PublicShareAuthenticator"), func() {
var authenticator Authenticator
BeforeEach(func() {
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
authenticator = PublicShareAuthenticator{
Logger: log.NewLogger(),
RevaGatewaySelector: pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return mockGatewayClient{
AuthenticateFunc: func(authType, clientID, clientSecret string) (string, rpcv1beta1.Code) {
if authType != "publicshares" {
return "", rpcv1beta1.Code_CODE_NOT_FOUND
}
if clientID == "sharetoken" && (clientSecret == "password|examples3cr3t" || clientSecret == "signature|examplesignature|exampleexpiration") {
return "exampletoken", rpcv1beta1.Code_CODE_OK
}
if clientID == "sharetoken" && clientSecret == "password|" {
return "otherexampletoken", rpcv1beta1.Code_CODE_OK
}
return "", rpcv1beta1.Code_CODE_NOT_FOUND
},
}
},
),
}
})
When("the request contains correct data", func() {
Context("using password authentication", func() {
It("should successfully authenticate", func() {
req := httptest.NewRequest(http.MethodGet, "http://example.com/dav/public-files/?public-token=sharetoken", http.NoBody)
req.SetBasicAuth("public", "examples3cr3t")
req2, valid := authenticator.Authenticate(req)
Expect(valid).To(Equal(true))
Expect(req2).ToNot(BeNil())
h := req2.Header
Expect(h.Get(headerRevaAccessToken)).To(Equal("exampletoken"))
})
})
Context("using signature authentication", func() {
It("should successfully authenticate", func() {
req := httptest.NewRequest(http.MethodGet, "http://example.com/dav/public-files/?public-token=sharetoken&signature=examplesignature&expiration=exampleexpiration", http.NoBody)
req2, valid := authenticator.Authenticate(req)
Expect(valid).To(Equal(true))
Expect(req2).ToNot(BeNil())
h := req2.Header
Expect(h.Get(headerRevaAccessToken)).To(Equal("exampletoken"))
})
})
})
When("the reguest is for the archiver", func() {
Context("using a public-token", func() {
It("should successfully authenticate", func() {
req := httptest.NewRequest(http.MethodGet, "http://example.com/archiver?public-token=sharetoken", http.NoBody)
req2, valid := authenticator.Authenticate(req)
Expect(valid).To(Equal(true))
Expect(req2).ToNot(BeNil())
h := req2.Header
Expect(h.Get(headerRevaAccessToken)).To(Equal("otherexampletoken"))
})
})
Context("not using a public-token", func() {
It("should fail to authenticate", func() {
req := httptest.NewRequest(http.MethodGet, "http://example.com/archiver", http.NoBody)
req2, valid := authenticator.Authenticate(req)
Expect(valid).To(Equal(false))
Expect(req2).To(BeNil())
})
})
})
})
type mockGatewayClient struct {
gatewayv1beta1.GatewayAPIClient
AuthenticateFunc func(authType, clientID, clientSecret string) (string, rpcv1beta1.Code)
}
func (c mockGatewayClient) Authenticate(ctx context.Context, in *gatewayv1beta1.AuthenticateRequest, opts ...grpc.CallOption) (*gatewayv1beta1.AuthenticateResponse, error) {
token, code := c.AuthenticateFunc(in.GetType(), in.GetClientId(), in.GetClientSecret())
return &gatewayv1beta1.AuthenticateResponse{
Status: &rpcv1beta1.Status{Code: code},
Token: token,
}, nil
}
@@ -0,0 +1,157 @@
package middleware
import (
"net/http"
"os"
"reflect"
gofig "github.com/gookit/config/v2"
"github.com/gookit/config/v2/yaml"
"github.com/qsfera/server/services/proxy/pkg/config"
"github.com/unrolled/secure"
"github.com/unrolled/secure/cspbuilder"
yamlv3 "gopkg.in/yaml.v3"
)
// LoadCSPConfig loads CSP header configuration from a yaml file.
func LoadCSPConfig(proxyCfg *config.Config) (*config.CSP, error) {
yamlContent, customYamlContent, err := loadCSPYaml(proxyCfg)
if err != nil {
return nil, err
}
return loadCSPConfig(yamlContent, customYamlContent)
}
// LoadCSPConfig loads CSP header configuration from a yaml file.
func loadCSPConfig(presetYamlContent, customYamlContent []byte) (*config.CSP, error) {
// substitute env vars and load to struct
gofig.WithOptions(gofig.ParseEnv)
gofig.AddDriver(yaml.Driver)
presetMap := map[string]any{}
err := yamlv3.Unmarshal(presetYamlContent, &presetMap)
if err != nil {
return nil, err
}
customMap := map[string]any{}
err = yamlv3.Unmarshal(customYamlContent, &customMap)
if err != nil {
return nil, err
}
mergedMap := deepMerge(presetMap, customMap)
mergedYamlContent, err := yamlv3.Marshal(mergedMap)
if err != nil {
return nil, err
}
err = gofig.LoadSources("yaml", mergedYamlContent)
if err != nil {
return nil, err
}
// read yaml
cspConfig := config.CSP{}
err = gofig.BindStruct("", &cspConfig)
if err != nil {
return nil, err
}
return &cspConfig, nil
}
// deepMerge recursively merges map2 into map1.
// - nested maps are merged recursively
// - slices are concatenated, preserving order and avoiding duplicates
// - scalar or type-mismatched values from map2 overwrite map1
func deepMerge(map1, map2 map[string]any) map[string]any {
if map1 == nil {
out := make(map[string]any, len(map2))
for k, v := range map2 {
out[k] = v
}
return out
}
for k, v2 := range map2 {
if v1, ok := map1[k]; ok {
// both maps -> recurse
if m1, ok1 := v1.(map[string]any); ok1 {
if m2, ok2 := v2.(map[string]any); ok2 {
map1[k] = deepMerge(m1, m2)
continue
}
}
// both slices -> merge unique
if s1, ok1 := v1.([]any); ok1 {
if s2, ok2 := v2.([]any); ok2 {
merged := append([]any{}, s1...)
for _, item := range s2 {
if !sliceContains(merged, item) {
merged = append(merged, item)
}
}
map1[k] = merged
continue
}
// s1 is slice, v2 single -> append if missing
if !sliceContains(s1, v2) {
map1[k] = append(s1, v2)
}
continue
}
// default: overwrite
map1[k] = v2
} else {
// new key -> just set
map1[k] = v2
}
}
return map1
}
func sliceContains(slice []any, val any) bool {
for _, v := range slice {
if reflect.DeepEqual(v, val) {
return true
}
}
return false
}
func loadCSPYaml(proxyCfg *config.Config) ([]byte, []byte, error) {
if proxyCfg.CSPConfigFileOverrideLocation != "" {
overrideCSPYaml, err := os.ReadFile(proxyCfg.CSPConfigFileOverrideLocation)
return overrideCSPYaml, []byte{}, err
}
if proxyCfg.CSPConfigFileLocation == "" {
return []byte(config.DefaultCSPConfig), nil, nil
}
customCSPYaml, err := os.ReadFile(proxyCfg.CSPConfigFileLocation)
return []byte(config.DefaultCSPConfig), customCSPYaml, err
}
// Security is a middleware to apply security relevant http headers like CSP.
func Security(cspConfig *config.CSP) func(h http.Handler) http.Handler {
cspBuilder := cspbuilder.Builder{
Directives: cspConfig.Directives,
}
secureMiddleware := secure.New(secure.Options{
BrowserXssFilter: true,
ContentSecurityPolicy: cspBuilder.MustBuild(),
ContentTypeNosniff: true,
CustomFrameOptionsValue: "SAMEORIGIN",
FrameDeny: true,
ReferrerPolicy: "strict-origin-when-cross-origin",
STSSeconds: 315360000,
STSPreload: true,
PermittedCrossDomainPolicies: "none",
RobotTag: "none",
})
return func(next http.Handler) http.Handler {
return secureMiddleware.Handler(next)
}
}
@@ -0,0 +1,40 @@
package middleware
import (
"testing"
"gotest.tools/v3/assert"
"gotest.tools/v3/assert/cmp"
)
func TestLoadCSPConfig(t *testing.T) {
// setup test env
presetYaml := `
directives:
frame-src:
- '''self'''
- 'https://embed.diagrams.net/'
- 'https://${ONLYOFFICE_DOMAIN|onlyoffice.qsfera.test}/'
- 'https://${COLLABORA_DOMAIN|collabora.qsfera.test}/'
`
customYaml := `
directives:
img-src:
- '''self'''
- 'data:'
frame-src:
- 'https://some.custom.domain/'
`
config, err := loadCSPConfig([]byte(presetYaml), []byte(customYaml))
if err != nil {
t.Error(err)
}
assert.Assert(t, cmp.Contains(config.Directives["frame-src"], "'self'"))
assert.Assert(t, cmp.Contains(config.Directives["frame-src"], "https://embed.diagrams.net/"))
assert.Assert(t, cmp.Contains(config.Directives["frame-src"], "https://onlyoffice.qsfera.test/"))
assert.Assert(t, cmp.Contains(config.Directives["frame-src"], "https://collabora.qsfera.test/"))
assert.Assert(t, cmp.Contains(config.Directives["img-src"], "'self'"))
assert.Assert(t, cmp.Contains(config.Directives["img-src"], "data:"))
}
@@ -0,0 +1,79 @@
package middleware
import (
"fmt"
"net/http"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/oidc"
"github.com/qsfera/server/services/proxy/pkg/config"
"github.com/qsfera/server/services/proxy/pkg/proxy/policy"
"go.opentelemetry.io/otel/trace"
)
// SelectorCookie provides a middleware which
func SelectorCookie(optionSetters ...Option) func(next http.Handler) http.Handler {
options := newOptions(optionSetters...)
logger := options.Logger
policySelector := options.PolicySelector
tracer := getTraceProvider(options).Tracer("proxy.middleware.selector_cookie")
return func(next http.Handler) http.Handler {
return &selectorCookie{
next: next,
logger: logger,
tracer: tracer,
policySelector: policySelector,
}
}
}
type selectorCookie struct {
next http.Handler
logger log.Logger
tracer trace.Tracer
policySelector config.PolicySelector
}
func (m selectorCookie) ServeHTTP(w http.ResponseWriter, req *http.Request) {
ctx, span := m.tracer.Start(req.Context(), fmt.Sprintf("%s %s", req.Method, req.URL.Path), trace.WithSpanKind(trace.SpanKindServer))
req = req.WithContext(ctx)
defer span.End()
if m.policySelector.Regex == nil && m.policySelector.Claims == nil {
// only set selector cookie for regex and claim selectors
span.End()
m.next.ServeHTTP(w, req)
return
}
selectorCookieName := ""
if m.policySelector.Regex != nil {
selectorCookieName = m.policySelector.Regex.SelectorCookieName
} else if m.policySelector.Claims != nil {
selectorCookieName = m.policySelector.Claims.SelectorCookieName
}
// update cookie
if oidc.FromContext(req.Context()) != nil {
selectorFunc, err := policy.LoadSelector(&m.policySelector)
if err != nil {
m.logger.Err(err)
}
selector, err := selectorFunc(req)
if err != nil {
m.logger.Err(err)
}
cookie := http.Cookie{
Name: selectorCookieName,
Value: selector,
Path: "/",
}
http.SetCookie(w, &cookie)
}
defer span.End()
m.next.ServeHTTP(w, req)
}
@@ -0,0 +1,323 @@
package middleware
import (
"crypto/sha512"
"encoding/hex"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/proxy/pkg/config"
"github.com/qsfera/server/services/proxy/pkg/user/backend"
"github.com/qsfera/server/services/proxy/pkg/userroles"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/signedurl"
microstore "go-micro.dev/v4/store"
"golang.org/x/crypto/pbkdf2"
)
const (
_paramOCSignature = "OC-Signature"
_paramOCCredential = "OC-Credential" // #nosec G101
_paramOCDate = "OC-Date"
_paramOCExpires = "OC-Expires"
_paramOCVerb = "OC-Verb"
_paramOCAlgo = "OC-Algo"
_paramOCJWTSig = "oc-jwt-sig"
)
var (
_requiredParams = [...]string{
_paramOCSignature,
_paramOCCredential,
_paramOCDate,
_paramOCExpires,
_paramOCVerb,
}
)
// SignedURLAuthenticator is the authenticator responsible for authenticating signed URL requests.
type SignedURLAuthenticator struct {
Logger log.Logger
PreSignedURLConfig config.PreSignedURL
UserProvider backend.UserBackend
UserRoleAssigner userroles.UserRoleAssigner
Store microstore.Store
Now func() time.Time
URLVerifier signedurl.Verifier
}
func (m SignedURLAuthenticator) shouldServeLegacy(req *http.Request) bool {
if !m.PreSignedURLConfig.Enabled {
return false
}
return req.URL.Query().Get(_paramOCSignature) != ""
}
func (m SignedURLAuthenticator) shouldServe(req *http.Request) bool {
if m.URLVerifier == nil {
return false
}
return req.URL.Query().Get(_paramOCJWTSig) != ""
}
func (m SignedURLAuthenticator) validate(req *http.Request) (err error) {
query := req.URL.Query()
if err := m.allRequiredParametersArePresent(query); err != nil {
return err
}
if err := m.requestMethodMatches(req.Method, query); err != nil {
return err
}
if err := m.requestMethodIsAllowed(req.Method); err != nil {
return err
}
if err = m.urlIsExpired(query); err != nil {
return err
}
if err := m.signatureIsValid(req); err != nil {
return err
}
return nil
}
func (m SignedURLAuthenticator) allRequiredParametersArePresent(query url.Values) (err error) {
// check if required query parameters exist in given request query parameters
// OC-Signature - the computed signature - server will verify the request upon this REQUIRED
// OC-Credential - defines the user scope (shall we use the qsfera user id here - this might leak internal data ....) REQUIRED
// OC-Date - defined the date the url was signed (ISO 8601 UTC) REQUIRED
// OC-Expires - defines the expiry interval in seconds (between 1 and 604800 = 7 days) REQUIRED
// TODO OC-Verb - defines for which http verb the request is valid - defaults to GET OPTIONAL
for _, p := range _requiredParams {
if query.Get(p) == "" {
return fmt.Errorf("required %s parameter not found", p)
}
}
return nil
}
func (m SignedURLAuthenticator) requestMethodMatches(meth string, query url.Values) (err error) {
// check if given url query parameter OC-Verb matches given request method
if !strings.EqualFold(meth, query.Get(_paramOCVerb)) {
return errors.New("required OC-Verb parameter did not match request method")
}
return nil
}
func (m SignedURLAuthenticator) requestMethodIsAllowed(meth string) (err error) {
// check if given request method is allowed
methodIsAllowed := false
for _, am := range m.PreSignedURLConfig.AllowedHTTPMethods {
if strings.EqualFold(meth, am) {
methodIsAllowed = true
break
}
}
if !methodIsAllowed {
return errors.New("request method is not listed in PreSignedURLConfig AllowedHTTPMethods")
}
return nil
}
func (m SignedURLAuthenticator) urlIsExpired(query url.Values) (err error) {
// check if url is expired by checking if given date (OC-Date) + expires in seconds (OC-Expires) is after now
validFrom, err := time.Parse(time.RFC3339, query.Get(_paramOCDate))
if err != nil {
return err
}
requestExpiry, err := time.ParseDuration(query.Get(_paramOCExpires) + "s")
if err != nil {
return err
}
validTo := validFrom.Add(requestExpiry)
if !(m.Now().Before(validTo)) {
return errors.New("URL is expired")
}
return nil
}
func (m SignedURLAuthenticator) signatureIsValid(req *http.Request) (err error) {
c := revactx.ContextMustGetUser(req.Context())
signingKey, err := m.Store.Read(c.Id.OpaqueId)
if err != nil {
m.Logger.Error().Err(err).Msg("could not retrieve signing key")
return err
}
if len(signingKey[0].Value) == 0 {
m.Logger.Error().Err(err).Msg("signing key empty")
return err
}
u := m.buildUrlToSign(req)
computedSignature := m.createSignature(u, signingKey[0].Value)
signatureInURL := req.URL.Query().Get(_paramOCSignature)
if computedSignature == signatureInURL {
return nil
}
// try a workaround for https://github.com/owncloud/ocis/issues/10180
// Some reverse proxies might replace $ with %24 in the URL leading to a mismatch in the signature
u = strings.Replace(u, "$", "%24", 1)
computedSignature = m.createSignature(u, signingKey[0].Value)
signatureInURL = req.URL.Query().Get(_paramOCSignature)
if computedSignature == signatureInURL {
return nil
}
return fmt.Errorf("signature mismatch: expected %s != actual %s", computedSignature, signatureInURL)
}
func (m SignedURLAuthenticator) buildUrlToSign(req *http.Request) string {
q := req.URL.Query()
// only params required for signing
signParameters := make(url.Values)
signParameters.Add(_paramOCCredential, q.Get(_paramOCCredential))
signParameters.Add(_paramOCDate, q.Get(_paramOCDate))
signParameters.Add(_paramOCExpires, q.Get(_paramOCExpires))
signParameters.Add(_paramOCVerb, q.Get(_paramOCVerb))
// remaining query params
q.Del(_paramOCAlgo)
q.Del(_paramOCCredential)
q.Del(_paramOCDate)
q.Del(_paramOCExpires)
q.Del(_paramOCSignature)
q.Del(_paramOCVerb)
url := *req.URL
if len(q) == 0 {
url.RawQuery = signParameters.Encode()
} else {
url.RawQuery = strings.Join([]string{q.Encode(), signParameters.Encode()}, "&")
}
u := url.String()
if !url.IsAbs() {
u = "https://" + req.Host + u // TODO where do we get the scheme
}
return u
}
func (m SignedURLAuthenticator) createSignature(url string, signingKey []byte) string {
// the oc10 signature check: $hash = \hash_pbkdf2("sha512", $url, $signingKey, 10000, 64, false);
// - sets the length of the output string to 64
// - sets raw output to false -> if raw_output is FALSE length corresponds to twice the byte-length of the derived key (as every byte of the key is returned as two hexits).
// TODO change to length 128 in oc10?
// fo golangs pbkdf2.Key we need to use 32 because it will be encoded into 64 hexits later
hash := pbkdf2.Key([]byte(url), signingKey, 10000, 32, sha512.New)
return hex.EncodeToString(hash)
}
// Authenticate implements the authenticator interface to authenticate requests via signed URL auth.
func (m SignedURLAuthenticator) Authenticate(r *http.Request) (*http.Request, bool) {
switch {
case m.shouldServeLegacy(r):
return m.authenticateLegacy(r)
case m.shouldServe(r):
return m.authenticate(r)
}
return nil, false
}
func (m SignedURLAuthenticator) authenticate(r *http.Request) (*http.Request, bool) {
u := r.URL.String()
if !r.URL.IsAbs() {
u = "https://" + r.Host + u
}
userid, err := m.URLVerifier.Verify(u)
if err != nil {
m.Logger.Error().
Err(err).
Str("authenticator", "signed_url_jwt").
Str("path", r.URL.Path).
Str("url", u).
Msg("Could not verify JWT signature")
return nil, false
}
user, _, err := m.UserProvider.GetUserByClaims(r.Context(), "userid", userid)
if err != nil {
m.Logger.Error().
Err(err).
Str("authenticator", "signed_url_jwt").
Str("path", r.URL.Path).
Msg("Could not get user by claim")
return nil, false
}
user, err = m.UserRoleAssigner.ApplyUserRole(r.Context(), user)
if err != nil {
m.Logger.Error().
Err(err).
Str("authenticator", "signed_url").
Str("path", r.URL.Path).
Msg("Could not get user by claim")
return nil, false
}
ctx := revactx.ContextSetUser(r.Context(), user)
r = r.WithContext(ctx)
m.Logger.Debug().
Str("authenticator", "signed_url").
Str("path", r.URL.Path).
Msg("successfully authenticated request")
return r, true
}
// authenticateLegacy is a helper function to authenticate requests that use the legacy
// client side signed URLs
func (m SignedURLAuthenticator) authenticateLegacy(r *http.Request) (*http.Request, bool) {
user, _, err := m.UserProvider.GetUserByClaims(r.Context(), "username", r.URL.Query().Get(_paramOCCredential))
if err != nil {
m.Logger.Error().
Err(err).
Str("authenticator", "signed_url").
Str("path", r.URL.Path).
Msg("Could not get user by claim")
return nil, false
}
user, err = m.UserRoleAssigner.ApplyUserRole(r.Context(), user)
if err != nil {
m.Logger.Error().
Err(err).
Str("authenticator", "signed_url").
Str("path", r.URL.Path).
Msg("Could not get user by claim")
return nil, false
}
ctx := revactx.ContextSetUser(r.Context(), user)
r = r.WithContext(ctx)
if err := m.validate(r); err != nil {
m.Logger.Error().
Err(err).
Str("authenticator", "signed_url").
Str("path", r.URL.Path).
Str("url", r.URL.String()).
Msg("Could not get user by claim")
return nil, false
}
m.Logger.Debug().
Str("authenticator", "signed_url").
Str("path", r.URL.Path).
Msg("successfully authenticated request")
return r, true
}
@@ -0,0 +1,250 @@
package middleware
import (
"context"
"net/http/httptest"
"testing"
"time"
userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/qsfera/server/services/proxy/pkg/config"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/signedurl"
"github.com/stretchr/testify/assert"
"go-micro.dev/v4/store"
)
func TestSignedURLAuthLegacy_shouldServe(t *testing.T) {
pua := SignedURLAuthenticator{}
tests := []struct {
url string
enabled bool
expected bool
}{
{"https://example.com/example.jpg", true, false},
{"https://example.com/example.jpg?OC-Signature=something", true, true},
{"https://example.com/example.jpg", false, false},
{"https://example.com/example.jpg?OC-Signature=something", false, false},
}
for _, tt := range tests {
pua.PreSignedURLConfig.Enabled = tt.enabled
r := httptest.NewRequest("", tt.url, nil)
result := pua.shouldServeLegacy(r)
if result != tt.expected {
t.Errorf("with %s expected %t got %t", tt.url, tt.expected, result)
}
}
}
func TestSignedURLAuth_shouldServe(t *testing.T) {
tests := []struct {
url string
secret string
enabled bool
expected bool
}{
{"https://example.com/example.jpg", "", true, false},
{"https://example.com/example.jpg", "", false, false},
{"https://example.com/example.jpg?oc-jwt-sig=something1", "secret", true, true},
{"https://example.com/example.jpg?oc-jwt-sig=something2", "", true, false},
{"https://example.com/example.jpg?oc-jwt-sig=something3", "secret", false, true},
}
for _, tt := range tests {
pua := SignedURLAuthenticator{}
pua.PreSignedURLConfig.Enabled = tt.enabled
if tt.secret != "" {
signURLVerifier, err := signedurl.NewJWTSignedURL(signedurl.WithSecret(tt.secret))
if err != nil {
t.Fatalf("failed to create signed URL verifier: %v", err)
}
pua.URLVerifier = signURLVerifier
}
r := httptest.NewRequest("", tt.url, nil)
result := pua.shouldServe(r)
if result != tt.expected {
t.Errorf("with %s expected %t got %t", tt.url, tt.expected, result)
}
}
}
func TestSignedURLAuth_allRequiredParametersPresent(t *testing.T) {
pua := SignedURLAuthenticator{}
baseURL := "https://example.com/example.jpg?"
tests := []struct {
params string
errorMessage string
}{
{"OC-Signature=something&OC-Credential=something&OC-Date=something&OC-Expires=something&OC-Verb=something", ""},
{"OC-Credential=something&OC-Date=something&OC-Expires=something&OC-Verb=something", "required OC-Signature parameter not found"},
{"OC-Signature=something&OC-Date=something&OC-Expires=something&OC-Verb=something", "required OC-Credential parameter not found"},
{"OC-Signature=something&OC-Credential=something&OC-Expires=something&OC-Verb=something", "required OC-Date parameter not found"},
{"OC-Signature=something&OC-Credential=something&OC-Date=something&OC-Verb=something", "required OC-Expires parameter not found"},
{"OC-Signature=something&OC-Credential=something&OC-Date=something&OC-Expires=something", "required OC-Verb parameter not found"},
}
for _, tt := range tests {
r := httptest.NewRequest("", baseURL+tt.params, nil)
err := pua.allRequiredParametersArePresent(r.URL.Query())
if tt.errorMessage != "" {
assert.EqualError(t, err, tt.errorMessage, tt.params)
} else {
assert.Nil(t, err)
}
}
}
func TestSignedURLAuth_requestMethodMatches(t *testing.T) {
pua := SignedURLAuthenticator{}
tests := []struct {
method string
url string
errorMessage string
}{
{"GET", "https://example.com/example.jpg?OC-Verb=GET", ""},
{"GET", "https://example.com/example.jpg?OC-Verb=get", ""},
{"POST", "https://example.com/example.jpg?OC-Verb=GET", "required OC-Verb parameter did not match request method"},
}
for _, tt := range tests {
r := httptest.NewRequest(tt.method, tt.url, nil)
err := pua.requestMethodMatches(r.Method, r.URL.Query())
if tt.errorMessage != "" {
assert.EqualError(t, err, tt.errorMessage, tt.url)
} else {
assert.Nil(t, err)
}
}
}
func TestSignedURLAuth_requestMethodIsAllowed(t *testing.T) {
pua := SignedURLAuthenticator{}
tests := []struct {
method string
allowed []string
errorMessage string
}{
{"GET", []string{}, "request method is not listed in PreSignedURLConfig AllowedHTTPMethods"},
{"GET", []string{"POST"}, "request method is not listed in PreSignedURLConfig AllowedHTTPMethods"},
{"GET", []string{"GET"}, ""},
{"GET", []string{"get"}, ""},
{"GET", []string{"POST", "GET"}, ""},
}
for _, tt := range tests {
pua.PreSignedURLConfig.AllowedHTTPMethods = tt.allowed
err := pua.requestMethodIsAllowed(tt.method)
if tt.errorMessage != "" {
assert.EqualError(t, err, tt.errorMessage)
} else {
assert.Nil(t, err)
}
}
}
func TestSignedURLAuth_urlIsExpired(t *testing.T) {
nowFunc := func() time.Time {
t, _ := time.Parse(time.RFC3339, "2020-02-02T12:30:00.000Z")
return t
}
pua := SignedURLAuthenticator{
Now: nowFunc,
}
tests := []struct {
url string
errorMessage string
}{
// a valid signed url
{"http://example.com/example.jpg?OC-Date=2020-02-02T12:29:00.000Z&OC-Expires=61", ""},
// invalid expiry
{"http://example.com/example.jpg?OC-Date=2020-02-02T12:29:00.000Z&OC-Expires=invalid", "time: invalid duration \"invalids\""},
// wrong date format on OC-Date
{"http://example.com/example.jpg?OC-Date=2020-02-02TTT12:29:00.000Z&OC-Expires=5", "parsing time \"2020-02-02TTT12:29:00.000Z\" as \"2006-01-02T15:04:05Z07:00\": cannot parse \"TT12:29:00.000Z\" as \"15\""},
// expired - 12:29:00 + 59s < 12:30
{"http://example.com/example.jpg?OC-Date=2020-02-02T12:29:00.000Z&OC-Expires=59", "URL is expired"},
// expired - basically url was created yesterday
{"http://example.com/example.jpg?OC-Date=2020-02-01T12:29:00.000Z&OC-Expires=59", "URL is expired"},
// future OC-Date - also valid now
{"http://example.com/example.jpg?OC-Date=2020-02-03T12:29:00.000Z&OC-Expires=59", ""},
}
for _, tt := range tests {
r := httptest.NewRequest("", tt.url, nil)
err := pua.urlIsExpired(r.URL.Query())
if tt.errorMessage != "" {
assert.EqualError(t, err, tt.errorMessage, tt.url)
} else {
assert.Nil(t, err)
}
}
}
func TestSignedURLAuth_createSignature(t *testing.T) {
pua := SignedURLAuthenticator{}
expected := "27d2ebea381384af3179235114801dcd00f91e46f99fca72575301cf3948101d"
s := pua.createSignature("something", []byte("somerandomkey"))
if s != expected {
t.Fail()
}
}
func TestSignedURLAuth_validate(t *testing.T) {
nowFunc := func() time.Time {
t, _ := time.Parse(time.RFC3339, "2020-02-02T12:30:00.000Z")
return t
}
cfg := config.PreSignedURL{
AllowedHTTPMethods: []string{"get"},
Enabled: true,
}
pua := SignedURLAuthenticator{
PreSignedURLConfig: cfg,
Store: store.NewMemoryStore(),
Now: nowFunc,
}
pua.Store.Write(&store.Record{
Key: "useri",
Value: []byte("1234567890"),
Metadata: nil,
})
tests := []struct {
now string
url string
errorMessage string
}{
{"2020-02-02T12:30:00.000Z", "http://example.com/example.jpg?OC-Date=2020-02-02T12:29:00.000Z&OC-Expires=invalid", "required OC-Signature parameter not found"},
{"2020-02-02T12:30:00.000Z", "http://cloud.example.net/?OC-Credential=alice&OC-Date=2019-05-14T11%3A01%3A58.135Z&OC-Expires=1200&OC-Verb=GET&OC-Signature=f9e53a1ee23caef10f72ec392c1b537317491b687bfdd224c782be197d9ca2b6", "URL is expired"},
{"2019-05-14T11:02:00.000Z", "http://cloud.example.net/?OC-Credential=alice&OC-Date=2019-05-14T11%3A01%3A58.135Z&OC-Expires=1200&OC-Verb=GET&OC-Signature=f9e53a1ee23caef10f72ec392c1b537317491b687bfdd224c782be197d9ca2b", "signature mismatch: expected f9e53a1ee23caef10f72ec392c1b537317491b687bfdd224c782be197d9ca2b6 != actual f9e53a1ee23caef10f72ec392c1b537317491b687bfdd224c782be197d9ca2b"},
{"2019-05-14T11:02:00.000Z", "http://cloud.example.net/?OC-Credential=alice&OC-Date=2019-05-14T11%3A01%3A58.135Z&OC-Expires=1200&OC-Verb=GET&OC-Signature=f9e53a1ee23caef10f72ec392c1b537317491b687bfdd224c782be197d9ca2b6", ""},
{"2019-05-14T11:02:00.000Z", "http://cloud.example.net/?OC-Date=2019-05-14T11%3A01%3A58.135Z&OC-Expires=1200&OC-Verb=GET&OC-Credential=alice&OC-Signature=f9e53a1ee23caef10f72ec392c1b537317491b687bfdd224c782be197d9ca2b6", ""},
{"2019-05-14T11:02:00.000Z", "http://cloud.example.net/?OC-Algo=PBKDF2%2F10000-SHA512&OC-Date=2019-05-14T11%3A01%3A58.135Z&OC-Expires=1200&OC-Verb=GET&OC-Credential=alice&OC-Signature=f9e53a1ee23caef10f72ec392c1b537317491b687bfdd224c782be197d9ca2b6", ""},
{"2024-02-07T12:03:11.966Z", "http://localhost:33001/try?id=1&id=2&OC-Credential=user&OC-Date=2024-02-07T12%3A03%3A11.966Z&OC-Expires=2&OC-Verb=GET&OC-Algo=PBKDF2%2F10000-SHA512&OC-Signature=86e21a1efbf0be989a206109cfedf70a22f338dc8995e849ce002032bc6741c5", ""},
}
for _, tt := range tests {
u := userpb.User{
Id: &userpb.UserId{OpaqueId: "useri"},
DisplayName: "Test User",
}
ctx := revactx.ContextSetUser(context.Background(), &u)
pua.Now = func() time.Time {
t, _ := time.Parse(time.RFC3339, tt.now)
return t
}
r := httptest.NewRequest("", tt.url, nil).WithContext(ctx)
err := pua.validate(r)
if tt.errorMessage == "" {
assert.Nil(t, err)
} else {
assert.EqualError(t, err, tt.errorMessage)
}
}
}
@@ -0,0 +1,35 @@
package middleware
import (
"net/http"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
)
// Tracer provides a middleware to start traces
func Tracer(tp trace.TracerProvider) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return &tracer{
next: next,
traceProvider: tp,
}
}
}
type tracer struct {
next http.Handler
traceProvider trace.TracerProvider
}
func (m tracer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
span := trace.SpanFromContext(r.Context())
span.SetAttributes(
attribute.KeyValue{
Key: "x-request-id",
Value: attribute.StringValue(chimiddleware.GetReqID(r.Context())),
})
m.next.ServeHTTP(w, r)
}