mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-04-24 04:58:31 -05:00
rename folder extensions -> services
Signed-off-by: Christian Richter <crichter@owncloud.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
)
|
||||
|
||||
// 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()
|
||||
wrap := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
next.ServeHTTP(wrap, r)
|
||||
|
||||
logger.Info().
|
||||
Str("proto", r.Proto).
|
||||
Str("request", chimiddleware.GetReqID(r.Context())).
|
||||
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,118 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/owncloud/ocis/v2/extensions/proxy/pkg/user/backend"
|
||||
|
||||
revactx "github.com/cs3org/reva/v2/pkg/ctx"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/oidc"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return &accountResolver{
|
||||
next: next,
|
||||
logger: logger,
|
||||
userProvider: options.UserProvider,
|
||||
userOIDCClaim: options.UserOIDCClaim,
|
||||
userCS3Claim: options.UserCS3Claim,
|
||||
autoProvisionAccounts: options.AutoprovisionAccounts,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type accountResolver struct {
|
||||
next http.Handler
|
||||
logger log.Logger
|
||||
userProvider backend.UserBackend
|
||||
autoProvisionAccounts bool
|
||||
userOIDCClaim string
|
||||
userCS3Claim string
|
||||
}
|
||||
|
||||
// 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 := req.Context()
|
||||
claims := oidc.FromContext(ctx)
|
||||
user, ok := revactx.ContextGetUser(ctx)
|
||||
token := ""
|
||||
// TODO what if an X-Access-Token is set? happens eg for download requests to the /data endpoint in the reva frontend
|
||||
|
||||
if claims == nil && !ok {
|
||||
m.next.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil && claims != nil {
|
||||
|
||||
var err error
|
||||
var value string
|
||||
var ok bool
|
||||
if value, ok = claims[m.userOIDCClaim].(string); !ok || value == "" {
|
||||
m.logger.Error().Str("claim", m.userOIDCClaim).Interface("claims", claims).Msg("claim not set or empty")
|
||||
w.WriteHeader(http.StatusInternalServerError) // admin needs to make the idp send the right claim
|
||||
return
|
||||
}
|
||||
|
||||
user, token, err = m.userProvider.GetUserByClaims(req.Context(), m.userCS3Claim, value, true)
|
||||
|
||||
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")
|
||||
user, err = m.userProvider.CreateUserFromClaims(req.Context(), claims)
|
||||
if err != nil {
|
||||
m.logger.Error().Err(err).Msg("Autoprovisioning user failed")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
var err error
|
||||
_, token, err = m.userProvider.GetUserByClaims(req.Context(), "username", user.Username, true)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
req.Header.Set(revactx.TokenHeader, token)
|
||||
|
||||
m.next.ServeHTTP(w, req)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
"github.com/cs3org/reva/v2/pkg/auth/scope"
|
||||
revactx "github.com/cs3org/reva/v2/pkg/ctx"
|
||||
"github.com/cs3org/reva/v2/pkg/token/manager/jwt"
|
||||
"github.com/owncloud/ocis/v2/extensions/proxy/pkg/config"
|
||||
"github.com/owncloud/ocis/v2/extensions/proxy/pkg/user/backend"
|
||||
"github.com/owncloud/ocis/v2/extensions/proxy/pkg/user/backend/test"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/oidc"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTokenIsAddedWithMailClaim(t *testing.T) {
|
||||
sut := newMockAccountResolver(&userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{Idp: "https://idx.example.com", OpaqueId: "123"},
|
||||
Mail: "foo@example.com",
|
||||
}, nil, oidc.Email, "mail")
|
||||
|
||||
req, rw := mockRequest(map[string]interface{}{
|
||||
oidc.Iss: "https://idx.example.com",
|
||||
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: "https://idx.example.com", OpaqueId: "123"},
|
||||
Mail: "foo@example.com",
|
||||
}, nil, oidc.PreferredUsername, "username")
|
||||
|
||||
req, rw := mockRequest(map[string]interface{}{
|
||||
oidc.Iss: "https://idx.example.com",
|
||||
oidc.PreferredUsername: "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")
|
||||
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")
|
||||
req, rw := mockRequest(map[string]interface{}{
|
||||
oidc.Iss: "https://idx.example.com",
|
||||
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")
|
||||
req, rw := mockRequest(map[string]interface{}{
|
||||
oidc.Iss: "https://idx.example.com",
|
||||
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")
|
||||
req, rw := mockRequest(map[string]interface{}{
|
||||
oidc.Iss: "https://idx.example.com",
|
||||
})
|
||||
|
||||
sut.ServeHTTP(rw, req)
|
||||
|
||||
token := req.Header.Get(revactx.TokenHeader)
|
||||
assert.Empty(t, token)
|
||||
assert.Equal(t, http.StatusInternalServerError, rw.Code)
|
||||
}
|
||||
|
||||
func newMockAccountResolver(userBackendResult *userv1beta1.User, userBackendErr error, oidcclaim, cs3claim string) http.Handler {
|
||||
tokenManager, _ := jwt.New(map[string]interface{}{
|
||||
"secret": "change-me",
|
||||
"expires": int64(60),
|
||||
})
|
||||
|
||||
token := ""
|
||||
if userBackendResult != nil {
|
||||
s, _ := scope.AddOwnerScope(nil)
|
||||
token, _ = tokenManager.MintToken(context.Background(), userBackendResult, s)
|
||||
}
|
||||
|
||||
mock := &test.UserBackendMock{
|
||||
GetUserByClaimsFunc: func(ctx context.Context, claim string, value string, withRoles bool) (*userv1beta1.User, string, error) {
|
||||
return userBackendResult, token, userBackendErr
|
||||
},
|
||||
}
|
||||
|
||||
return AccountResolver(
|
||||
Logger(log.NewLogger()),
|
||||
UserProvider(mock),
|
||||
TokenManagerConfig(config.TokenManager{JWTSecret: "secret"}),
|
||||
UserOIDCClaim(oidcclaim),
|
||||
UserCS3Claim(cs3claim),
|
||||
AutoprovisionAccounts(false),
|
||||
)(mockHandler{})
|
||||
}
|
||||
|
||||
func mockRequest(claims map[string]interface{}) (*http.Request, *httptest.ResponseRecorder) {
|
||||
if claims == nil {
|
||||
return httptest.NewRequest("GET", "http://example.com/foo", nil), httptest.NewRecorder()
|
||||
}
|
||||
|
||||
ctx := oidc.NewContext(context.Background(), claims)
|
||||
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) {}
|
||||
@@ -0,0 +1,132 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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 oCIS. It is a list of strings
|
||||
// to be regexp compiled.
|
||||
ProxyWwwAuthenticate = []string{"/ocs/v[12].php/cloud/"}
|
||||
|
||||
// WWWAuthenticate captures the Www-Authenticate header string.
|
||||
WWWAuthenticate = "Www-Authenticate"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Authentication is a higher order authentication middleware.
|
||||
func Authentication(opts ...Option) func(next http.Handler) http.Handler {
|
||||
options := newOptions(opts...)
|
||||
|
||||
configureSupportedChallenges(options)
|
||||
oidc := newOIDCAuth(options)
|
||||
basic := newBasicAuth(options)
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if options.OIDCIss != "" && options.EnableBasicAuth {
|
||||
oidc(basic(next)).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
if options.OIDCIss != "" && !options.EnableBasicAuth {
|
||||
oidc(next).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
if options.OIDCIss == "" && options.EnableBasicAuth {
|
||||
basic(next).ServeHTTP(w, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
for i := 0; i < len(SupportedAuthStrategies); i++ {
|
||||
w.Header().Add(WWWAuthenticate, fmt.Sprintf("%v realm=\"%s\", charset=\"UTF-8\"", strings.Title(SupportedAuthStrategies[i]), r.Host))
|
||||
}
|
||||
}
|
||||
|
||||
func removeSuperfluousAuthenticate(w http.ResponseWriter) {
|
||||
w.Header().Del(WWWAuthenticate)
|
||||
}
|
||||
|
||||
// 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 i := 0; i < len(ProxyWwwAuthenticate); i++ {
|
||||
evalRequestURI(&u, i)
|
||||
}
|
||||
}
|
||||
|
||||
func evalRequestURI(l *userAgentLocker, i int) {
|
||||
r := regexp.MustCompile(ProxyWwwAuthenticate[i])
|
||||
if r.Match([]byte(l.r.RequestURI)) {
|
||||
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\"", strings.Title(v), l.r.Host))
|
||||
return
|
||||
}
|
||||
}
|
||||
l.w.Header().Add(WWWAuthenticate, fmt.Sprintf("%v realm=\"%s\", charset=\"UTF-8\"", strings.Title(l.fallback), l.r.Host))
|
||||
}
|
||||
}
|
||||
|
||||
// newOIDCAuth returns a configured oidc middleware
|
||||
func newOIDCAuth(options Options) func(http.Handler) http.Handler {
|
||||
return OIDCAuth(
|
||||
Logger(options.Logger),
|
||||
OIDCProviderFunc(options.OIDCProviderFunc),
|
||||
HTTPClient(options.HTTPClient),
|
||||
OIDCIss(options.OIDCIss),
|
||||
TokenCacheSize(options.UserinfoCacheSize),
|
||||
TokenCacheTTL(options.UserinfoCacheTTL),
|
||||
CredentialsByUserAgent(options.CredentialsByUserAgent),
|
||||
)
|
||||
}
|
||||
|
||||
// newBasicAuth returns a configured basic middleware
|
||||
func newBasicAuth(options Options) func(http.Handler) http.Handler {
|
||||
return BasicAuth(
|
||||
UserProvider(options.UserProvider),
|
||||
Logger(options.Logger),
|
||||
EnableBasicAuth(options.EnableBasicAuth),
|
||||
OIDCIss(options.OIDCIss),
|
||||
UserOIDCClaim(options.UserOIDCClaim),
|
||||
UserCS3Claim(options.UserCS3Claim),
|
||||
CredentialsByUserAgent(options.CredentialsByUserAgent),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/owncloud/ocis/v2/extensions/proxy/pkg/user/backend"
|
||||
"github.com/owncloud/ocis/v2/extensions/proxy/pkg/webdav"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/oidc"
|
||||
)
|
||||
|
||||
// BasicAuth provides a middleware to check if BasicAuth is provided
|
||||
func BasicAuth(optionSetters ...Option) func(next http.Handler) http.Handler {
|
||||
options := newOptions(optionSetters...)
|
||||
logger := options.Logger
|
||||
|
||||
if options.EnableBasicAuth {
|
||||
options.Logger.Warn().Msg("basic auth enabled, use only for testing or development")
|
||||
}
|
||||
|
||||
h := basicAuth{
|
||||
logger: logger,
|
||||
enabled: options.EnableBasicAuth,
|
||||
userProvider: options.UserProvider,
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(
|
||||
func(w http.ResponseWriter, req *http.Request) {
|
||||
if h.isPublicLink(req) || !h.isBasicAuth(req) || h.isOIDCTokenAuth(req) {
|
||||
if !h.isPublicLink(req) {
|
||||
userAgentAuthenticateLockIn(w, req, options.CredentialsByUserAgent, "basic")
|
||||
}
|
||||
next.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
removeSuperfluousAuthenticate(w)
|
||||
login, password, _ := req.BasicAuth()
|
||||
user, _, err := h.userProvider.Authenticate(req.Context(), login, password)
|
||||
|
||||
// touch is a user agent locking guard, when touched changes to true it indicates the User-Agent on the
|
||||
// request is configured to support only one challenge, it it remains untouched, there are no considera-
|
||||
// tions and we should write all available authentication challenges to the response.
|
||||
touch := false
|
||||
|
||||
if err != nil {
|
||||
for k, v := range options.CredentialsByUserAgent {
|
||||
if strings.Contains(k, req.UserAgent()) {
|
||||
removeSuperfluousAuthenticate(w)
|
||||
w.Header().Add("Www-Authenticate", fmt.Sprintf("%v realm=\"%s\", charset=\"UTF-8\"", strings.Title(v), req.Host))
|
||||
touch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// if the request is not bound to any user agent, write all available challenges
|
||||
if !touch {
|
||||
writeSupportedAuthenticateHeader(w, req)
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
|
||||
if webdav.IsWebdavRequest(req) {
|
||||
b, err := webdav.Marshal(webdav.Exception{
|
||||
Code: webdav.SabredavPermissionDenied,
|
||||
Message: "Authentication error",
|
||||
})
|
||||
|
||||
webdav.HandleWebdavError(w, b, err)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// fake oidc claims
|
||||
claims := map[string]interface{}{
|
||||
oidc.Iss: user.Id.Idp,
|
||||
oidc.PreferredUsername: user.Username,
|
||||
oidc.Email: user.Mail,
|
||||
oidc.OwncloudUUID: user.Id.OpaqueId,
|
||||
}
|
||||
|
||||
if options.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[options.UserOIDCClaim] = user.Id.OpaqueId
|
||||
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, req.WithContext(oidc.NewContext(req.Context(), claims)))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type basicAuth struct {
|
||||
logger log.Logger
|
||||
enabled bool
|
||||
userProvider backend.UserBackend
|
||||
}
|
||||
|
||||
func (m basicAuth) isPublicLink(req *http.Request) bool {
|
||||
login, _, ok := req.BasicAuth()
|
||||
|
||||
if !ok || login != "public" {
|
||||
return false
|
||||
}
|
||||
|
||||
publicPaths := []string{
|
||||
"/dav/public-files/",
|
||||
"/remote.php/dav/public-files/",
|
||||
"/remote.php/ocs/apps/files_sharing/api/v1/tokeninfo/unprotected",
|
||||
"/ocs/v1.php/cloud/capabilities",
|
||||
"/data",
|
||||
}
|
||||
isPublic := false
|
||||
|
||||
for _, p := range publicPaths {
|
||||
if strings.HasPrefix(req.URL.Path, p) {
|
||||
isPublic = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return isPublic
|
||||
}
|
||||
|
||||
// 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 (m basicAuth) isOIDCTokenAuth(req *http.Request) bool {
|
||||
return req.URL.Path == "/konnect/v1/token"
|
||||
}
|
||||
|
||||
func (m basicAuth) isBasicAuth(req *http.Request) bool {
|
||||
_, _, ok := req.BasicAuth()
|
||||
return m.enabled && ok
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
/**/
|
||||
|
||||
func TestBasicAuth__isPublicLink(t *testing.T) {
|
||||
tests := []struct {
|
||||
url string
|
||||
username string
|
||||
expected bool
|
||||
}{
|
||||
{url: "/remote.php/dav/public-files/", username: "", expected: false},
|
||||
{url: "/remote.php/dav/public-files/", username: "abc", expected: false},
|
||||
{url: "/remote.php/dav/public-files/", username: "private", expected: false},
|
||||
{url: "/remote.php/dav/public-files/", username: "public", expected: true},
|
||||
{url: "/ocs/v1.php/cloud/capabilities", username: "", expected: false},
|
||||
{url: "/ocs/v1.php/cloud/capabilities", username: "abc", expected: false},
|
||||
{url: "/ocs/v1.php/cloud/capabilities", username: "private", expected: false},
|
||||
{url: "/ocs/v1.php/cloud/capabilities", username: "public", expected: true},
|
||||
{url: "/ocs/v1.php/cloud/users/admin", username: "public", expected: false},
|
||||
}
|
||||
ba := basicAuth{}
|
||||
|
||||
for _, tt := range tests {
|
||||
req := httptest.NewRequest("", tt.url, nil)
|
||||
|
||||
if tt.username != "" {
|
||||
req.SetBasicAuth(tt.username, "")
|
||||
}
|
||||
|
||||
result := ba.isPublicLink(req)
|
||||
if result != tt.expected {
|
||||
t.Errorf("with %s expected %t got %t", tt.url, tt.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
revactx "github.com/cs3org/reva/v2/pkg/ctx"
|
||||
"github.com/cs3org/reva/v2/pkg/rgrpc/status"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"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
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return &createHome{
|
||||
next: next,
|
||||
logger: logger,
|
||||
revaGatewayClient: options.RevaGatewayClient,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type createHome struct {
|
||||
next http.Handler
|
||||
logger log.Logger
|
||||
revaGatewayClient gateway.GatewayAPIClient
|
||||
}
|
||||
|
||||
func (m createHome) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
if !m.shouldServe(req) {
|
||||
m.next.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
token := req.Header.Get("x-access-token")
|
||||
|
||||
// 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{}
|
||||
createHomeRes, err := m.revaGatewayClient.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")
|
||||
}
|
||||
}
|
||||
|
||||
m.next.ServeHTTP(w, req)
|
||||
}
|
||||
|
||||
func (m createHome) shouldServe(req *http.Request) bool {
|
||||
return req.Header.Get("x-access-token") != ""
|
||||
}
|
||||
@@ -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,178 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
|
||||
gOidc "github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/owncloud/ocis/v2/extensions/proxy/pkg/config"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/oidc"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/sync"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// OIDCProvider used to mock the oidc provider during tests
|
||||
type OIDCProvider interface {
|
||||
UserInfo(ctx context.Context, ts oauth2.TokenSource) (*gOidc.UserInfo, error)
|
||||
}
|
||||
|
||||
// OIDCAuth provides a middleware to check access secured by a static token.
|
||||
func OIDCAuth(optionSetters ...Option) func(next http.Handler) http.Handler {
|
||||
options := newOptions(optionSetters...)
|
||||
tokenCache := sync.NewCache(options.UserinfoCacheSize)
|
||||
|
||||
h := oidcAuth{
|
||||
logger: options.Logger,
|
||||
providerFunc: options.OIDCProviderFunc,
|
||||
httpClient: options.HTTPClient,
|
||||
oidcIss: options.OIDCIss,
|
||||
TokenManagerConfig: options.TokenManagerConfig,
|
||||
tokenCache: &tokenCache,
|
||||
tokenCacheTTL: options.UserinfoCacheTTL,
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
// there is no bearer token on the request,
|
||||
if !h.shouldServe(req) {
|
||||
// oidc supported but token not present, add header and handover to the next middleware.
|
||||
userAgentAuthenticateLockIn(w, req, options.CredentialsByUserAgent, "bearer")
|
||||
next.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
if h.getProvider() == nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(req.Header.Get("Authorization"), "Bearer ")
|
||||
|
||||
claims, status := h.getClaims(token, req)
|
||||
if status != 0 {
|
||||
w.WriteHeader(status)
|
||||
return
|
||||
}
|
||||
|
||||
// inject claims to the request context for the account_uuid middleware.
|
||||
req = req.WithContext(oidc.NewContext(req.Context(), claims))
|
||||
|
||||
// store claims in context
|
||||
// uses the original context, not the one with probably reduced security
|
||||
next.ServeHTTP(w, req.WithContext(oidc.NewContext(req.Context(), claims)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type oidcAuth struct {
|
||||
logger log.Logger
|
||||
provider OIDCProvider
|
||||
providerFunc func() (OIDCProvider, error)
|
||||
httpClient *http.Client
|
||||
oidcIss string
|
||||
tokenCache *sync.Cache
|
||||
tokenCacheTTL time.Duration
|
||||
TokenManagerConfig config.TokenManager
|
||||
}
|
||||
|
||||
func (m oidcAuth) getClaims(token string, req *http.Request) (claims map[string]interface{}, status int) {
|
||||
hit := m.tokenCache.Load(token)
|
||||
if hit == nil {
|
||||
// TODO cache userinfo for access token if we can determine the expiry (which works in case it is a jwt based access token)
|
||||
oauth2Token := &oauth2.Token{
|
||||
AccessToken: token,
|
||||
}
|
||||
|
||||
userInfo, err := m.getProvider().UserInfo(
|
||||
context.WithValue(req.Context(), oauth2.HTTPClient, m.httpClient),
|
||||
oauth2.StaticTokenSource(oauth2Token),
|
||||
)
|
||||
if err != nil {
|
||||
m.logger.Error().Err(err).Msg("Failed to get userinfo")
|
||||
status = http.StatusUnauthorized
|
||||
return
|
||||
}
|
||||
|
||||
if err := userInfo.Claims(&claims); err != nil {
|
||||
m.logger.Error().Err(err).Interface("userinfo", userInfo).Msg("failed to unmarshal userinfo claims")
|
||||
status = http.StatusInternalServerError
|
||||
return
|
||||
}
|
||||
|
||||
expiration := m.extractExpiration(token)
|
||||
m.tokenCache.Store(token, claims, expiration)
|
||||
|
||||
m.logger.Debug().Interface("claims", claims).Interface("userInfo", userInfo).Time("expiration", expiration.UTC()).Msg("unmarshalled and cached userinfo")
|
||||
return
|
||||
}
|
||||
|
||||
var ok bool
|
||||
if claims, ok = hit.V.(map[string]interface{}); !ok {
|
||||
status = http.StatusInternalServerError
|
||||
return
|
||||
}
|
||||
m.logger.Debug().Interface("claims", claims).Msg("cache hit for userinfo")
|
||||
return
|
||||
}
|
||||
|
||||
// extractExpiration tries to parse and extract the expiration from the provided token. It might not even be a jwt.
|
||||
// defaults to the configured fallback TTL.
|
||||
// TODO: use introspection endpoint if available in the oidc configuration. Still needs a fallback to configured TTL.
|
||||
func (m oidcAuth) extractExpiration(token string) time.Time {
|
||||
defaultExpiration := time.Now().Add(m.tokenCacheTTL)
|
||||
|
||||
t, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(m.TokenManagerConfig.JWTSecret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return defaultExpiration
|
||||
}
|
||||
|
||||
at, ok := t.Claims.(jwt.StandardClaims)
|
||||
if !ok || at.ExpiresAt == 0 {
|
||||
return defaultExpiration
|
||||
}
|
||||
|
||||
return time.Unix(at.ExpiresAt, 0)
|
||||
}
|
||||
|
||||
func (m oidcAuth) shouldServe(req *http.Request) bool {
|
||||
header := req.Header.Get("Authorization")
|
||||
|
||||
if m.oidcIss == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// todo: looks dirty, check later
|
||||
// TODO: make a PR to coreos/go-oidc for exposing userinfo endpoint on provider, see https://github.com/coreos/go-oidc/issues/248
|
||||
for _, ignoringPath := range []string{"/konnect/v1/userinfo", "/status.php"} {
|
||||
if req.URL.Path == ignoringPath {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return strings.HasPrefix(header, "Bearer ")
|
||||
}
|
||||
|
||||
func (m *oidcAuth) getProvider() OIDCProvider {
|
||||
if m.provider == nil {
|
||||
// Lazily initialize a provider
|
||||
|
||||
// provider needs to be cached as when it is created
|
||||
// it will fetch the keys from the issuer using the .well-known
|
||||
// endpoint
|
||||
provider, err := m.providerFunc()
|
||||
if err != nil {
|
||||
m.logger.Error().Err(err).Msg("could not initialize oidcAuth provider")
|
||||
return nil
|
||||
}
|
||||
|
||||
m.provider = provider
|
||||
}
|
||||
return m.provider
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
func TestOIDCAuthMiddleware(t *testing.T) {
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
||||
|
||||
m := OIDCAuth(
|
||||
Logger(log.NewLogger()),
|
||||
OIDCProviderFunc(func() (OIDCProvider, error) {
|
||||
return mockOP(false), nil
|
||||
}),
|
||||
OIDCIss("https://localhost:9200"),
|
||||
)(next)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "https://idp.example.com", nil)
|
||||
r.Header.Set("Authorization", "Bearer sometoken")
|
||||
w := httptest.NewRecorder()
|
||||
m.ServeHTTP(w, r)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected an internal server error")
|
||||
}
|
||||
}
|
||||
|
||||
type mockOIDCProvider struct {
|
||||
UserInfoFunc func(ctx context.Context, ts oauth2.TokenSource) (*oidc.UserInfo, error)
|
||||
}
|
||||
|
||||
// UserInfo will panic if the function has been called, but not mocked
|
||||
func (m mockOIDCProvider) UserInfo(ctx context.Context, ts oauth2.TokenSource) (*oidc.UserInfo, error) {
|
||||
if m.UserInfoFunc != nil {
|
||||
return m.UserInfoFunc(ctx, ts)
|
||||
}
|
||||
|
||||
panic("UserInfo was called in test but not mocked")
|
||||
}
|
||||
|
||||
func mockOP(retErr bool) OIDCProvider {
|
||||
if retErr {
|
||||
return &mockOIDCProvider{
|
||||
UserInfoFunc: func(ctx context.Context, ts oauth2.TokenSource) (*oidc.UserInfo, error) {
|
||||
return nil, fmt.Errorf("error returned by mockOIDCProvider UserInfo")
|
||||
},
|
||||
}
|
||||
|
||||
}
|
||||
return &mockOIDCProvider{
|
||||
UserInfoFunc: func(ctx context.Context, ts oauth2.TokenSource) (*oidc.UserInfo, error) {
|
||||
ui := &oidc.UserInfo{
|
||||
// claims: private ...
|
||||
}
|
||||
return ui, nil
|
||||
},
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/owncloud/ocis/v2/extensions/proxy/pkg/user/backend"
|
||||
|
||||
settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
|
||||
storesvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/store/v0"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
"github.com/owncloud/ocis/v2/extensions/proxy/pkg/config"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
)
|
||||
|
||||
// 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
|
||||
// TokenManagerConfig for communicating with the reva token manager
|
||||
TokenManagerConfig config.TokenManager
|
||||
// PolicySelectorConfig for using the policy selector
|
||||
PolicySelector config.PolicySelector
|
||||
// HTTPClient to use for communication with the oidcAuth provider
|
||||
HTTPClient *http.Client
|
||||
// UP
|
||||
UserProvider backend.UserBackend
|
||||
// SettingsRoleService for the roles API in settings
|
||||
SettingsRoleService settingssvc.RoleService
|
||||
// OIDCProviderFunc to lazily initialize an oidc provider, must be set for the oidc_auth middleware
|
||||
OIDCProviderFunc func() (OIDCProvider, error)
|
||||
// OIDCIss is the oidcAuth-issuer
|
||||
OIDCIss string
|
||||
// RevaGatewayClient to send requests to the reva gateway
|
||||
RevaGatewayClient gateway.GatewayAPIClient
|
||||
// Store for persisting data
|
||||
Store storesvc.StoreService
|
||||
// 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
|
||||
// AutoprovisionAccounts when an accountResolver does not exist.
|
||||
AutoprovisionAccounts bool
|
||||
// EnableBasicAuth to allow basic auth
|
||||
EnableBasicAuth bool
|
||||
// UserinfoCacheSize defines the max number of entries in the userinfo cache, intended for the oidc_auth middleware
|
||||
UserinfoCacheSize int
|
||||
// UserinfoCacheTTL sets the max cache duration for the userinfo cache, intended for the oidc_auth middleware
|
||||
UserinfoCacheTTL time.Duration
|
||||
// CredentialsByUserAgent sets the auth challenges on a per user-agent basis
|
||||
CredentialsByUserAgent map[string]string
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// TokenManagerConfig provides a function to set the token manger config option.
|
||||
func TokenManagerConfig(cfg config.TokenManager) Option {
|
||||
return func(o *Options) {
|
||||
o.TokenManagerConfig = cfg
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// OIDCProviderFunc provides a function to set the the oidc provider function option.
|
||||
func OIDCProviderFunc(f func() (OIDCProvider, error)) Option {
|
||||
return func(o *Options) {
|
||||
o.OIDCProviderFunc = f
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// RevaGatewayClient provides a function to set the the reva gateway service client option.
|
||||
func RevaGatewayClient(gc gateway.GatewayAPIClient) Option {
|
||||
return func(o *Options) {
|
||||
o.RevaGatewayClient = gc
|
||||
}
|
||||
}
|
||||
|
||||
// Store provides a function to set the store option.
|
||||
func Store(sc storesvc.StoreService) Option {
|
||||
return func(o *Options) {
|
||||
o.Store = sc
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// TokenCacheSize provides a function to set the TokenCacheSize
|
||||
func TokenCacheSize(size int) Option {
|
||||
return func(o *Options) {
|
||||
o.UserinfoCacheSize = size
|
||||
}
|
||||
}
|
||||
|
||||
// TokenCacheTTL provides a function to set the TokenCacheTTL
|
||||
func TokenCacheTTL(ttl time.Duration) Option {
|
||||
return func(o *Options) {
|
||||
o.UserinfoCacheTTL = ttl
|
||||
}
|
||||
}
|
||||
|
||||
// UserProvider sets the accounts user provider
|
||||
func UserProvider(up backend.UserBackend) Option {
|
||||
return func(o *Options) {
|
||||
o.UserProvider = up
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
)
|
||||
|
||||
const (
|
||||
headerRevaAccessToken = "x-access-token"
|
||||
headerShareToken = "public-token"
|
||||
basicAuthPasswordPrefix = "password|"
|
||||
authenticationType = "publicshares"
|
||||
)
|
||||
|
||||
// PublicShareAuth ...
|
||||
func PublicShareAuth(opts ...Option) func(next http.Handler) http.Handler {
|
||||
options := newOptions(opts...)
|
||||
logger := options.Logger
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
shareToken := r.Header.Get(headerShareToken)
|
||||
if shareToken == "" {
|
||||
shareToken = r.URL.Query().Get(headerShareToken)
|
||||
}
|
||||
|
||||
// Currently we only want to authenticate app open request coming from public shares.
|
||||
if shareToken == "" {
|
||||
// Don't authenticate
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
var sharePassword string
|
||||
if signature := r.URL.Query().Get("signature"); signature != "" {
|
||||
expiration := r.URL.Query().Get("expiration")
|
||||
if expiration == "" {
|
||||
logger.Warn().Str("signature", signature).Msg("cannot do signature auth without the expiration")
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
authResp, err := options.RevaGatewayClient.Authenticate(r.Context(), &gateway.AuthenticateRequest{
|
||||
Type: authenticationType,
|
||||
ClientId: shareToken,
|
||||
ClientSecret: sharePassword,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Str("public_share_token", shareToken).Msg("could not authenticate public share")
|
||||
// try another middleware
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
r.Header.Add(headerRevaAccessToken, authResp.Token)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/owncloud/ocis/v2/extensions/proxy/pkg/config"
|
||||
"github.com/owncloud/ocis/v2/extensions/proxy/pkg/proxy/policy"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/oidc"
|
||||
)
|
||||
|
||||
// SelectorCookie provides a middleware which
|
||||
func SelectorCookie(optionSetters ...Option) func(next http.Handler) http.Handler {
|
||||
options := newOptions(optionSetters...)
|
||||
logger := options.Logger
|
||||
policySelector := options.PolicySelector
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return &selectorCookie{
|
||||
next: next,
|
||||
logger: logger,
|
||||
policySelector: policySelector,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type selectorCookie struct {
|
||||
next http.Handler
|
||||
logger log.Logger
|
||||
policySelector config.PolicySelector
|
||||
}
|
||||
|
||||
func (m selectorCookie) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
if m.policySelector.Regex == nil && m.policySelector.Claims == nil {
|
||||
// only set selector cookie for regex and claim selectors
|
||||
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)
|
||||
}
|
||||
|
||||
m.next.ServeHTTP(w, req)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
revactx "github.com/cs3org/reva/v2/pkg/ctx"
|
||||
"github.com/owncloud/ocis/v2/extensions/proxy/pkg/config"
|
||||
"github.com/owncloud/ocis/v2/extensions/proxy/pkg/user/backend"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
storemsg "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/store/v0"
|
||||
storesvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/store/v0"
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
||||
// SignedURLAuth provides a middleware to check access secured by a signed URL.
|
||||
func SignedURLAuth(optionSetters ...Option) func(next http.Handler) http.Handler {
|
||||
options := newOptions(optionSetters...)
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return &signedURLAuth{
|
||||
next: next,
|
||||
logger: options.Logger,
|
||||
preSignedURLConfig: options.PreSignedURLConfig,
|
||||
store: options.Store,
|
||||
userProvider: options.UserProvider,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type signedURLAuth struct {
|
||||
next http.Handler
|
||||
logger log.Logger
|
||||
preSignedURLConfig config.PreSignedURL
|
||||
userProvider backend.UserBackend
|
||||
store storesvc.StoreService
|
||||
}
|
||||
|
||||
func (m signedURLAuth) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
if !m.shouldServe(req) {
|
||||
m.next.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
user, _, err := m.userProvider.GetUserByClaims(req.Context(), "username", req.URL.Query().Get("OC-Credential"), true)
|
||||
if err != nil {
|
||||
m.logger.Error().Err(err).Msg("Could not get user by claim")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
ctx := revactx.ContextSetUser(req.Context(), user)
|
||||
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
if err := m.validate(req); err != nil {
|
||||
http.Error(w, "Invalid url signature", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
m.next.ServeHTTP(w, req)
|
||||
}
|
||||
|
||||
func (m signedURLAuth) shouldServe(req *http.Request) bool {
|
||||
if !m.preSignedURLConfig.Enabled {
|
||||
return false
|
||||
}
|
||||
return req.URL.Query().Get("OC-Signature") != ""
|
||||
}
|
||||
|
||||
func (m signedURLAuth) validate(req *http.Request) (err error) {
|
||||
query := req.URL.Query()
|
||||
|
||||
if ok, err := m.allRequiredParametersArePresent(query); !ok {
|
||||
return err
|
||||
}
|
||||
|
||||
if ok, err := m.requestMethodMatches(req.Method, query); !ok {
|
||||
return err
|
||||
}
|
||||
|
||||
if ok, err := m.requestMethodIsAllowed(req.Method); !ok {
|
||||
return err
|
||||
}
|
||||
|
||||
if expired, err := m.urlIsExpired(query, time.Now); expired {
|
||||
return err
|
||||
}
|
||||
|
||||
if ok, err := m.signatureIsValid(req); !ok {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m signedURLAuth) allRequiredParametersArePresent(query url.Values) (ok bool, 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 owncloud 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 []string{
|
||||
"OC-Signature",
|
||||
"OC-Credential",
|
||||
"OC-Date",
|
||||
"OC-Expires",
|
||||
"OC-Verb",
|
||||
} {
|
||||
if query.Get(p) == "" {
|
||||
return false, fmt.Errorf("required %s parameter not found", p)
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m signedURLAuth) requestMethodMatches(meth string, query url.Values) (ok bool, err error) {
|
||||
// check if given url query parameter OC-Verb matches given request method
|
||||
if !strings.EqualFold(meth, query.Get("OC-Verb")) {
|
||||
return false, errors.New("required OC-Verb parameter did not match request method")
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m signedURLAuth) requestMethodIsAllowed(meth string) (ok bool, 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 false, errors.New("request method is not listed in PreSignedURLConfig AllowedHTTPMethods")
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
func (m signedURLAuth) urlIsExpired(query url.Values, now func() time.Time) (expired bool, 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("OC-Date"))
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
|
||||
requestExpiry, err := time.ParseDuration(query.Get("OC-Expires") + "s")
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
|
||||
validTo := validFrom.Add(requestExpiry)
|
||||
|
||||
return !(now().After(validFrom) && now().Before(validTo)), nil
|
||||
}
|
||||
|
||||
func (m signedURLAuth) signatureIsValid(req *http.Request) (ok bool, err error) {
|
||||
u := revactx.ContextMustGetUser(req.Context())
|
||||
signingKey, err := m.getSigningKey(req.Context(), u.Id.OpaqueId)
|
||||
if err != nil {
|
||||
m.logger.Error().Err(err).Msg("could not retrieve signing key")
|
||||
return false, err
|
||||
}
|
||||
if len(signingKey) == 0 {
|
||||
m.logger.Error().Err(err).Msg("signing key empty")
|
||||
return false, err
|
||||
}
|
||||
q := req.URL.Query()
|
||||
signature := q.Get("OC-Signature")
|
||||
q.Del("OC-Signature")
|
||||
req.URL.RawQuery = q.Encode()
|
||||
url := req.URL.String()
|
||||
if !req.URL.IsAbs() {
|
||||
url = "https://" + req.Host + url // TODO where do we get the scheme from
|
||||
}
|
||||
|
||||
return m.createSignature(url, signingKey) == signature, nil
|
||||
}
|
||||
|
||||
func (m signedURLAuth) 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)
|
||||
}
|
||||
|
||||
func (m signedURLAuth) getSigningKey(ctx context.Context, ocisID string) ([]byte, error) {
|
||||
res, err := m.store.Read(ctx, &storesvc.ReadRequest{
|
||||
Options: &storemsg.ReadOptions{
|
||||
Database: "proxy",
|
||||
Table: "signing-keys",
|
||||
},
|
||||
Key: ocisID,
|
||||
})
|
||||
if err != nil || len(res.Records) < 1 {
|
||||
return []byte{}, err
|
||||
}
|
||||
|
||||
return res.Records[0].Value, nil
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSignedURLAuth_shouldServe(t *testing.T) {
|
||||
pua := signedURLAuth{}
|
||||
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.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 := signedURLAuth{}
|
||||
baseURL := "https://example.com/example.jpg?"
|
||||
tests := []struct {
|
||||
params string
|
||||
expected bool
|
||||
}{
|
||||
{"OC-Signature=something&OC-Credential=something&OC-Date=something&OC-Expires=something&OC-Verb=something", true},
|
||||
{"OC-Credential=something&OC-Date=something&OC-Expires=something&OC-Verb=something", false},
|
||||
{"OC-Signature=something&OC-Date=something&OC-Expires=something&OC-Verb=something", false},
|
||||
{"OC-Signature=something&OC-Credential=something&OC-Expires=something&OC-Verb=something", false},
|
||||
{"OC-Signature=something&OC-Credential=something&OC-Date=something&OC-Verb=something", false},
|
||||
{"OC-Signature=something&OC-Credential=something&OC-Date=something&OC-Expires=something", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
r := httptest.NewRequest("", baseURL+tt.params, nil)
|
||||
ok, _ := pua.allRequiredParametersArePresent(r.URL.Query())
|
||||
if ok != tt.expected {
|
||||
t.Errorf("with %s expected %t got %t", tt.params, tt.expected, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedURLAuth_requestMethodMatches(t *testing.T) {
|
||||
pua := signedURLAuth{}
|
||||
tests := []struct {
|
||||
method string
|
||||
url string
|
||||
expected bool
|
||||
}{
|
||||
{"GET", "https://example.com/example.jpg?OC-Verb=GET", true},
|
||||
{"GET", "https://example.com/example.jpg?OC-Verb=get", true},
|
||||
{"POST", "https://example.com/example.jpg?OC-Verb=GET", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
r := httptest.NewRequest(tt.method, tt.url, nil)
|
||||
ok, _ := pua.requestMethodMatches(r.Method, r.URL.Query())
|
||||
if ok != tt.expected {
|
||||
t.Errorf("with method %s and url %s expected %t got %t", tt.method, tt.url, tt.expected, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedURLAuth_requestMethodIsAllowed(t *testing.T) {
|
||||
pua := signedURLAuth{}
|
||||
tests := []struct {
|
||||
method string
|
||||
allowed []string
|
||||
expected bool
|
||||
}{
|
||||
{"GET", []string{}, false},
|
||||
{"GET", []string{"POST"}, false},
|
||||
{"GET", []string{"GET"}, true},
|
||||
{"GET", []string{"get"}, true},
|
||||
{"GET", []string{"POST", "GET"}, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
pua.preSignedURLConfig.AllowedHTTPMethods = tt.allowed
|
||||
ok, _ := pua.requestMethodIsAllowed(tt.method)
|
||||
|
||||
if ok != tt.expected {
|
||||
t.Errorf("with method %s and allowed methods %s expected %t got %t", tt.method, tt.allowed, tt.expected, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedURLAuth_urlIsExpired(t *testing.T) {
|
||||
pua := signedURLAuth{}
|
||||
nowFunc := func() time.Time {
|
||||
t, _ := time.Parse(time.RFC3339, "2020-02-02T12:30:00.000Z")
|
||||
return t
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
url string
|
||||
isExpired bool
|
||||
}{
|
||||
{"http://example.com/example.jpg?OC-Date=2020-02-02T12:29:00.000Z&OC-Expires=61", false},
|
||||
{"http://example.com/example.jpg?OC-Date=2020-02-02T12:29:00.000Z&OC-Expires=invalid", true},
|
||||
{"http://example.com/example.jpg?OC-Date=2020-02-02T12:29:00.000Z&OC-Expires=59", true},
|
||||
{"http://example.com/example.jpg?OC-Date=2020-02-03T12:29:00.000Z&OC-Expires=59", true},
|
||||
{"http://example.com/example.jpg?OC-Date=2020-02-01T12:29:00.000Z&OC-Expires=59", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
r := httptest.NewRequest("", tt.url, nil)
|
||||
expired, _ := pua.urlIsExpired(r.URL.Query(), nowFunc)
|
||||
if expired != tt.isExpired {
|
||||
t.Errorf("with %s expected %t got %t", tt.url, tt.isExpired, expired)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedURLAuth_createSignature(t *testing.T) {
|
||||
pua := signedURLAuth{}
|
||||
expected := "27d2ebea381384af3179235114801dcd00f91e46f99fca72575301cf3948101d"
|
||||
s := pua.createSignature("something", []byte("somerandomkey"))
|
||||
|
||||
if s != expected {
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user