Initial QSfera import
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user