mirror of
https://github.com/owncloud/ocis
synced 2026-04-25 17:25:21 +02:00
Merge pull request #11603 from owncloud/oidc_claims_checker
feat: add a way to check for specific OIDC claims
This commit is contained in:
@@ -1877,7 +1877,7 @@
|
||||
"description": "OpenID Connect scope for add acr (authentication context class reference) to the token",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "false",
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "false"
|
||||
},
|
||||
"protocolMappers": [
|
||||
@@ -2899,7 +2899,7 @@
|
||||
"config": {}
|
||||
}
|
||||
],
|
||||
"browserFlow": "browser",
|
||||
"browserFlow": "step up flow",
|
||||
"registrationFlow": "registration",
|
||||
"directGrantFlow": "direct grant",
|
||||
"resetCredentialsFlow": "reset credentials",
|
||||
|
||||
@@ -81,6 +81,8 @@ services:
|
||||
OCIS_PASSWORD_POLICY_BANNED_PASSWORDS_LIST: "banned-password-list.txt"
|
||||
PROXY_CSP_CONFIG_FILE_LOCATION: /etc/ocis/csp.yaml
|
||||
KEYCLOAK_DOMAIN: ${KEYCLOAK_DOMAIN:-keycloak.owncloud.test}
|
||||
OCIS_MFA_ENABLED: ${OCIS_MFA_ENABLED:-false}
|
||||
WEB_OIDC_SCOPE: "openid profile email acr"
|
||||
volumes:
|
||||
- ./config/ocis/banned-password-list.txt:/etc/ocis/banned-password-list.txt
|
||||
- ./config/ocis/csp.yaml:/etc/ocis/csp.yaml
|
||||
|
||||
59
ocis-pkg/mfa/mfa.go
Normal file
59
ocis-pkg/mfa/mfa.go
Normal file
@@ -0,0 +1,59 @@
|
||||
// Package mfa provides functionality for multi-factor authentication (MFA).
|
||||
// mfa_test.go contains usage examples and tests.
|
||||
package mfa
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// MFAHeader is the header to be used across grpc and http services
|
||||
// to forward the access token.
|
||||
const MFAHeader = "X-Multi-Factor-Authentication"
|
||||
|
||||
// MFARequiredHeader is the header returned by the server if step-up authentication is required.
|
||||
const MFARequiredHeader = "X-Ocis-Mfa-Required"
|
||||
|
||||
type mfaKeyType struct{}
|
||||
|
||||
var mfaKey = mfaKeyType{}
|
||||
|
||||
// EnhanceRequest enhances the request context with the MFA status from the header.
|
||||
// This operation does not overwrite existing context values.
|
||||
func EnhanceRequest(req *http.Request) *http.Request {
|
||||
ctx := req.Context()
|
||||
if Has(ctx) {
|
||||
return req
|
||||
}
|
||||
return req.WithContext(Set(ctx, req.Header.Get(MFAHeader) == "true"))
|
||||
}
|
||||
|
||||
// SetRequiredStatus sets the MFA required header and the statuscode to 403
|
||||
func SetRequiredStatus(w http.ResponseWriter) {
|
||||
w.Header().Set(MFARequiredHeader, "true")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}
|
||||
|
||||
// Has returns the mfa status from the context.
|
||||
func Has(ctx context.Context) bool {
|
||||
mfa, ok := ctx.Value(mfaKey).(bool)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return mfa
|
||||
}
|
||||
|
||||
// Set stores the mfa status in the context.
|
||||
func Set(ctx context.Context, mfa bool) context.Context {
|
||||
return context.WithValue(ctx, mfaKey, mfa)
|
||||
}
|
||||
|
||||
// SetHeader sets the MFA header.
|
||||
func SetHeader(r *http.Request, mfa bool) {
|
||||
if mfa {
|
||||
r.Header.Set(MFAHeader, "true")
|
||||
return
|
||||
}
|
||||
|
||||
r.Header.Set(MFAHeader, "false")
|
||||
}
|
||||
68
ocis-pkg/mfa/mfa_test.go
Normal file
68
ocis-pkg/mfa/mfa_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package mfa_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/mfa"
|
||||
"github.com/test-go/testify/require"
|
||||
)
|
||||
|
||||
func exampleUsage() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// In a central place of your service enhance request once.
|
||||
// Note: This will not overwrite existing context values so it's safe (but unnecessary) to call multiple times.
|
||||
r = mfa.EnhanceRequest(r)
|
||||
|
||||
// somewhere in your code extract the context
|
||||
ctx := r.Context()
|
||||
|
||||
// now you can check if the user has MFA enabled
|
||||
if !mfa.Has(ctx) {
|
||||
// use this line to log access denied information
|
||||
// mfa package will not log anything by itself
|
||||
mfa.SetRequiredStatus(w)
|
||||
return
|
||||
}
|
||||
// user has MFA enabled, you can now proceed with sensitive operation
|
||||
}
|
||||
}
|
||||
|
||||
func TestMFALifecycle(t *testing.T) {
|
||||
testCases := []struct {
|
||||
Alias string
|
||||
HasMFA bool
|
||||
ShouldHaveMFA bool
|
||||
ResponseCode int
|
||||
}{
|
||||
{
|
||||
Alias: "simple",
|
||||
HasMFA: true,
|
||||
ResponseCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Alias: "denied",
|
||||
HasMFA: false,
|
||||
ResponseCode: http.StatusForbidden,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "http://url&method.doesnt.matter", nil)
|
||||
mfa.SetHeader(r, tc.HasMFA)
|
||||
|
||||
exampleUsage().ServeHTTP(w, r)
|
||||
res := w.Result()
|
||||
|
||||
require.Equal(t, tc.ResponseCode, res.StatusCode, tc.Alias)
|
||||
if tc.ResponseCode == http.StatusForbidden {
|
||||
require.Equal(t, "true", res.Header.Get(mfa.MFARequiredHeader), tc.Alias)
|
||||
} else {
|
||||
require.Empty(t, res.Header.Get(mfa.MFARequiredHeader), tc.Alias)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -63,6 +63,8 @@ type Config struct {
|
||||
|
||||
ServerManagedSpaces bool `yaml:"server_managed_spaces" env:"OCIS_CLAIM_MANAGED_SPACES_ENABLED" desc:"Enables Space management through OIDC claims. See the text description for more details." introductionVersion:"7.2.0"`
|
||||
|
||||
MultiFactorAuthentication MFAConfig `yaml:"mfa"`
|
||||
|
||||
Context context.Context `yaml:"-"`
|
||||
}
|
||||
|
||||
@@ -200,3 +202,9 @@ type PasswordPolicy struct {
|
||||
type Validation struct {
|
||||
MaxTagLength int `yaml:"max_tag_length" env:"OCIS_MAX_TAG_LENGTH" desc:"Define the maximum tag length. Defaults to 100 if not set. Set to 0 to not limit the tag length. Changes only impact the validation of new tags." introductionVersion:"7.2.0"`
|
||||
}
|
||||
|
||||
// MFAConfig configures multi factor multifactor authentication
|
||||
type MFAConfig struct {
|
||||
Enabled bool `yaml:"enabled" env:"OCIS_MFA_ENABLED" desc:"Set to true to enable multi factor authentication. See the documentation for more details." introductionVersion:"Balch"`
|
||||
AuthLevelNames []string `yaml:"auth_level_names" env:"OCIS_MFA_AUTH_LEVEL_NAMES" desc:"This authentication level name indicates that multi-factor authentication was performed. The name must match the ACR claim in the access token received. Note: If multiple names are required, use a comma-separated list. The front-end service will use the first name in the list when requesting multi-factor authentication (MFA)." introductionVersion:"Balch"`
|
||||
}
|
||||
|
||||
@@ -141,6 +141,9 @@ func DefaultConfig() *config.Config {
|
||||
Validation: config.Validation{
|
||||
MaxTagLength: 100,
|
||||
},
|
||||
MultiFactorAuthentication: config.MFAConfig{
|
||||
AuthLevelNames: []string{"advanced"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -340,6 +340,12 @@ func FrontendConfigFromStruct(cfg *config.Config, logger log.Logger) (map[string
|
||||
"endpoints": []string{"list", "get", "delete"},
|
||||
"configurable": cfg.ConfigurableNotifications,
|
||||
},
|
||||
"auth": map[string]interface{}{
|
||||
"mfa": map[string]interface{}{
|
||||
"enabled": cfg.MultiFactorAuthentication.Enabled,
|
||||
"levelnames": cfg.MultiFactorAuthentication.AuthLevelNames,
|
||||
},
|
||||
},
|
||||
},
|
||||
"version": map[string]interface{}{
|
||||
"product": "Infinite Scale",
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/account"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/mfa"
|
||||
opkgm "github.com/owncloud/ocis/v2/ocis-pkg/middleware"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/errorcode"
|
||||
"github.com/owncloud/reva/v2/pkg/auth/scope"
|
||||
@@ -42,6 +43,8 @@ func Auth(opts ...account.Option) func(http.Handler) http.Handler {
|
||||
}
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r = mfa.EnhanceRequest(r)
|
||||
|
||||
ctx := r.Context()
|
||||
t := r.Header.Get("x-access-token")
|
||||
if t == "" {
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/l10n"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/mfa"
|
||||
v0 "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/settings/v0"
|
||||
settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/errorcode"
|
||||
@@ -132,6 +133,13 @@ func (g Graph) GetAllDrives(version APIVersion) http.HandlerFunc {
|
||||
// GetAllDrivesV1 attempts to retrieve the current users drives;
|
||||
// it includes another user's drives, if the current user has the permission.
|
||||
func (g Graph) GetAllDrivesV1(w http.ResponseWriter, r *http.Request) {
|
||||
if !mfa.Has(r.Context()) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
logger.Error().Str("path", r.URL.Path).Msg("MFA required but not satisfied")
|
||||
mfa.SetRequiredStatus(w)
|
||||
return
|
||||
}
|
||||
|
||||
spaces, errCode := g.getDrives(r, true, APIVersion_1)
|
||||
if errCode != nil {
|
||||
errorcode.RenderError(w, r, errCode)
|
||||
@@ -152,6 +160,13 @@ func (g Graph) GetAllDrivesV1(w http.ResponseWriter, r *http.Request) {
|
||||
// it includes the grantedtoV2 property
|
||||
// it uses unified roles instead of the cs3 representations
|
||||
func (g Graph) GetAllDrivesV1Beta1(w http.ResponseWriter, r *http.Request) {
|
||||
if !mfa.Has(r.Context()) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
logger.Error().Str("path", r.URL.Path).Msg("MFA required but not satisfied")
|
||||
mfa.SetRequiredStatus(w)
|
||||
return
|
||||
}
|
||||
|
||||
drives, errCode := g.getDrives(r, true, APIVersion_1_Beta_1)
|
||||
if errCode != nil {
|
||||
errorcode.RenderError(w, r, errCode)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/CiscoM31/godata"
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -152,3 +153,39 @@ func parseIDParam(r *http.Request, param string) (storageprovider.ResourceId, er
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// regular users can only search for terms with a minimum length
|
||||
func hasAcceptableSearch(query *godata.GoDataQuery, minSearchLength int) bool {
|
||||
if query == nil || query.Search == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if strings.HasPrefix(query.Search.RawValue, "\"") {
|
||||
// if search starts with double quotes then it must finish with double quotes
|
||||
// add +2 to the minimum search length in this case
|
||||
minSearchLength += 2
|
||||
}
|
||||
|
||||
return len(query.Search.RawValue) >= minSearchLength
|
||||
}
|
||||
|
||||
// regular users can only filter by userType
|
||||
func hasAcceptableFilter(query *godata.GoDataQuery) bool {
|
||||
switch {
|
||||
case query == nil || query.Filter == nil:
|
||||
return true
|
||||
case query.Filter.Tree.Token.Type != godata.ExpressionTokenLogical:
|
||||
return false
|
||||
case query.Filter.Tree.Token.Value != "eq":
|
||||
return false
|
||||
case query.Filter.Tree.Children[0].Token.Value != "userType":
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// regular users can only use basic queries without any expansions, computes or applies
|
||||
func hasAcceptableQuery(query *godata.GoDataQuery) bool {
|
||||
return query != nil && query.Apply == nil && query.Expand == nil && query.Compute == nil
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/tidwall/gjson"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/mfa"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/shared"
|
||||
v0 "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/settings/v0"
|
||||
settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
|
||||
@@ -116,19 +117,32 @@ var _ = Describe("Graph", func() {
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("can list an empty list of all spaces", func() {
|
||||
It("can list an empty list of all spaces when having 2fa", func() {
|
||||
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Times(1).Return(&provider.ListStorageSpacesResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
StorageSpaces: []*provider.StorageSpace{},
|
||||
}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/drives", nil)
|
||||
r = r.WithContext(ctx)
|
||||
r = r.WithContext(mfa.Set(ctx, true))
|
||||
rr := httptest.NewRecorder()
|
||||
svc.GetAllDrivesV1(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("denies getting all spaces when not having 2fa", func() {
|
||||
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Times(1).Return(&provider.ListStorageSpacesResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
StorageSpaces: []*provider.StorageSpace{},
|
||||
}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/drives", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
svc.GetAllDrivesV1(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusForbidden))
|
||||
Expect(rr.Header().Get("X-Ocis-Mfa-Required")).To(Equal("true"))
|
||||
})
|
||||
|
||||
It("can list a space without owner", func() {
|
||||
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Times(1).Return(&provider.ListStorageSpacesResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/CiscoM31/godata"
|
||||
libregraph "github.com/owncloud/libre-graph-api-go"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/mfa"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/errorcode"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -32,28 +33,34 @@ func (g Graph) GetGroups(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
ctxHasFullPerms := g.contextUserHasFullAccountPerms(r.Context())
|
||||
searchHasAcceptableLength := false
|
||||
if odataReq.Query != nil && odataReq.Query.Search != nil {
|
||||
minSearchLength := g.config.API.IdentitySearchMinLength
|
||||
if strings.HasPrefix(odataReq.Query.Search.RawValue, "\"") {
|
||||
// if search starts with double quotes then it must finish with double quotes
|
||||
// add +2 to the minimum search length in this case
|
||||
minSearchLength += 2
|
||||
hasMFA := mfa.Has(r.Context())
|
||||
if !hasAcceptableSearch(odataReq.Query, g.config.API.IdentitySearchMinLength) {
|
||||
if !ctxHasFullPerms {
|
||||
// for regular user the search term must have a minimum length
|
||||
logger.Debug().Interface("query", r.URL.Query()).Msgf("search with less than %d chars for a regular user", g.config.API.IdentitySearchMinLength)
|
||||
errorcode.AccessDenied.Render(w, r, http.StatusForbidden, "search term too short")
|
||||
return
|
||||
}
|
||||
if !hasMFA {
|
||||
logger.Error().Str("path", r.URL.Path).Msg("MFA required but not satisfied")
|
||||
mfa.SetRequiredStatus(w)
|
||||
return
|
||||
}
|
||||
searchHasAcceptableLength = len(odataReq.Query.Search.RawValue) >= minSearchLength
|
||||
}
|
||||
if !ctxHasFullPerms && !searchHasAcceptableLength {
|
||||
// for regular user the search term must have a minimum length
|
||||
logger.Debug().Interface("query", r.URL.Query()).Msgf("search with less than %d chars for a regular user", g.config.API.IdentitySearchMinLength)
|
||||
errorcode.AccessDenied.Render(w, r, http.StatusForbidden, "search term too short")
|
||||
return
|
||||
}
|
||||
|
||||
if !ctxHasFullPerms && (odataReq.Query.Filter != nil || odataReq.Query.Apply != nil || odataReq.Query.Expand != nil || odataReq.Query.Compute != nil) {
|
||||
// regular users can't use filter, apply, expand and compute
|
||||
logger.Debug().Interface("query", r.URL.Query()).Msg("forbidden query elements for a regular user")
|
||||
errorcode.AccessDenied.Render(w, r, http.StatusForbidden, "query has forbidden elements for regular users")
|
||||
return
|
||||
if !hasAcceptableQuery(odataReq.Query) {
|
||||
if !ctxHasFullPerms {
|
||||
// regular users can't use filter, apply, expand and compute
|
||||
logger.Debug().Interface("query", r.URL.Query()).Msg("forbidden query elements for a regular user")
|
||||
errorcode.AccessDenied.Render(w, r, http.StatusForbidden, "query has forbidden elements for regular users")
|
||||
return
|
||||
}
|
||||
|
||||
if !hasMFA {
|
||||
logger.Error().Str("path", r.URL.Path).Msg("MFA required but not satisfied")
|
||||
mfa.SetRequiredStatus(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
groups, err := g.identityBackend.GetGroups(r.Context(), odataReq)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
libregraph "github.com/owncloud/libre-graph-api-go"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/mfa"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/shared"
|
||||
settingsmsg "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/settings/v0"
|
||||
settings "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
|
||||
@@ -108,9 +109,10 @@ var _ = Describe("Groups", func() {
|
||||
Constraint: settingsmsg.Permission_CONSTRAINT_ALL,
|
||||
},
|
||||
}, nil)
|
||||
identityBackend.On("GetGroups", ctx, mock.Anything).Return([]*libregraph.Group{newGroup}, nil)
|
||||
identityBackend.On("GetGroups", mock.Anything, mock.Anything).Return([]*libregraph.Group{newGroup}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups?$orderby=invalid", nil)
|
||||
r = r.WithContext(mfa.Set(r.Context(), true))
|
||||
svc.GetGroups(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
@@ -130,9 +132,10 @@ var _ = Describe("Groups", func() {
|
||||
Constraint: settingsmsg.Permission_CONSTRAINT_ALL,
|
||||
},
|
||||
}, nil)
|
||||
identityBackend.On("GetGroups", ctx, mock.Anything).Return(nil, errors.New("failed"))
|
||||
identityBackend.On("GetGroups", mock.Anything, mock.Anything).Return(nil, errors.New("failed"))
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups", nil)
|
||||
r = r.WithContext(mfa.Set(r.Context(), true))
|
||||
svc.GetGroups(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
@@ -151,9 +154,10 @@ var _ = Describe("Groups", func() {
|
||||
Constraint: settingsmsg.Permission_CONSTRAINT_ALL,
|
||||
},
|
||||
}, nil)
|
||||
identityBackend.On("GetGroups", ctx, mock.Anything).Return(nil, errorcode.New(errorcode.AccessDenied, "access denied"))
|
||||
identityBackend.On("GetGroups", mock.Anything, mock.Anything).Return(nil, errorcode.New(errorcode.AccessDenied, "access denied"))
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups", nil)
|
||||
r = r.WithContext(mfa.Set(r.Context(), true))
|
||||
svc.GetGroups(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusForbidden))
|
||||
@@ -166,16 +170,17 @@ var _ = Describe("Groups", func() {
|
||||
Expect(odataerr.Error.Code).To(Equal("accessDenied"))
|
||||
})
|
||||
|
||||
It("renders an empty list of groups", func() {
|
||||
It("renders an empty list of groups with 2fa", func() {
|
||||
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{
|
||||
Permission: &settingsmsg.Permission{
|
||||
Operation: settingsmsg.Permission_OPERATION_UNKNOWN,
|
||||
Constraint: settingsmsg.Permission_CONSTRAINT_ALL,
|
||||
},
|
||||
}, nil)
|
||||
identityBackend.On("GetGroups", ctx, mock.Anything).Return([]*libregraph.Group{}, nil)
|
||||
identityBackend.On("GetGroups", mock.Anything, mock.Anything).Return([]*libregraph.Group{}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups", nil)
|
||||
r = r.WithContext(mfa.Set(r.Context(), true))
|
||||
svc.GetGroups(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
@@ -188,16 +193,17 @@ var _ = Describe("Groups", func() {
|
||||
Expect(res.Value).To(Equal([]interface{}{}))
|
||||
})
|
||||
|
||||
It("renders a list of groups", func() {
|
||||
It("renders a list of groups with 2fa", func() {
|
||||
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{
|
||||
Permission: &settingsmsg.Permission{
|
||||
Operation: settingsmsg.Permission_OPERATION_UNKNOWN,
|
||||
Constraint: settingsmsg.Permission_CONSTRAINT_ALL,
|
||||
},
|
||||
}, nil)
|
||||
identityBackend.On("GetGroups", ctx, mock.Anything).Return([]*libregraph.Group{newGroup}, nil)
|
||||
identityBackend.On("GetGroups", mock.Anything, mock.Anything).Return([]*libregraph.Group{newGroup}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups", nil)
|
||||
r = r.WithContext(mfa.Set(r.Context(), true))
|
||||
svc.GetGroups(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
@@ -211,6 +217,21 @@ var _ = Describe("Groups", func() {
|
||||
Expect(len(res.Value)).To(Equal(1))
|
||||
Expect(res.Value[0].GetId()).To(Equal("group1"))
|
||||
})
|
||||
It("denies accessing a list of groups without 2fa", func() {
|
||||
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{
|
||||
Permission: &settingsmsg.Permission{
|
||||
Operation: settingsmsg.Permission_OPERATION_UNKNOWN,
|
||||
Constraint: settingsmsg.Permission_CONSTRAINT_ALL,
|
||||
},
|
||||
}, nil)
|
||||
identityBackend.On("GetGroups", ctx, mock.Anything).Return([]*libregraph.Group{newGroup}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups", nil)
|
||||
svc.GetGroups(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusForbidden))
|
||||
Expect(rr.Header().Get("X-Ocis-Mfa-Required")).To(Equal("true"))
|
||||
})
|
||||
It("denies listing for unprivileged users", func() {
|
||||
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{}, nil)
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/users", nil)
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/go-chi/render"
|
||||
"github.com/google/uuid"
|
||||
libregraph "github.com/owncloud/libre-graph-api-go"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/mfa"
|
||||
settingsmsg "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/settings/v0"
|
||||
settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/errorcode"
|
||||
@@ -204,6 +205,7 @@ func (g Graph) contextUserHasFullAccountPerms(reqctx context.Context) bool {
|
||||
if pr.Permission.Constraint != defaults.All {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -220,42 +222,49 @@ func (g Graph) GetUsers(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
ctxHasFullPerms := g.contextUserHasFullAccountPerms(r.Context())
|
||||
searchHasAcceptableLength := false
|
||||
if odataReq.Query != nil && odataReq.Query.Search != nil {
|
||||
minSearchLength := g.config.API.IdentitySearchMinLength
|
||||
if strings.HasPrefix(odataReq.Query.Search.RawValue, "\"") {
|
||||
// if search starts with double quotes then it must finish with double quotes
|
||||
// add +2 to the minimum search length in this case
|
||||
minSearchLength += 2
|
||||
hasMFA := mfa.Has(r.Context())
|
||||
if !hasAcceptableSearch(odataReq.Query, g.config.API.IdentitySearchMinLength) {
|
||||
if !ctxHasFullPerms {
|
||||
// for regular user the search term must have a minimum length
|
||||
logger.Debug().Interface("query", r.URL.Query()).Msgf("search with less than %d chars for a regular user", g.config.API.IdentitySearchMinLength)
|
||||
errorcode.AccessDenied.Render(w, r, http.StatusForbidden, "search term too short")
|
||||
return
|
||||
}
|
||||
if !hasMFA {
|
||||
logger.Error().Str("path", r.URL.Path).Msg("MFA required but not satisfied")
|
||||
mfa.SetRequiredStatus(w)
|
||||
return
|
||||
}
|
||||
searchHasAcceptableLength = len(odataReq.Query.Search.RawValue) >= minSearchLength
|
||||
}
|
||||
if !ctxHasFullPerms && !searchHasAcceptableLength {
|
||||
// for regular user the search term must have a minimum length
|
||||
logger.Debug().Interface("query", r.URL.Query()).Msgf("search with less than %d chars for a regular user", g.config.API.IdentitySearchMinLength)
|
||||
errorcode.AccessDenied.Render(w, r, http.StatusForbidden, "search term too short")
|
||||
return
|
||||
}
|
||||
|
||||
if !ctxHasFullPerms && odataReq.Query.Filter != nil {
|
||||
// regular users are allowed to filter only by userType
|
||||
filter := odataReq.Query.Filter
|
||||
switch {
|
||||
case filter.Tree.Token.Type != godata.ExpressionTokenLogical:
|
||||
fallthrough
|
||||
case filter.Tree.Token.Value != "eq":
|
||||
fallthrough
|
||||
case filter.Tree.Children[0].Token.Value != "userType":
|
||||
if !hasAcceptableFilter(odataReq.Query) {
|
||||
if !ctxHasFullPerms {
|
||||
// regular users are allowed to filter only by userType
|
||||
logger.Debug().Interface("query", r.URL.Query()).Msg("forbidden filter for a regular user")
|
||||
errorcode.AccessDenied.Render(w, r, http.StatusForbidden, "filter has forbidden elements for regular users")
|
||||
return
|
||||
}
|
||||
|
||||
if !hasMFA {
|
||||
logger.Error().Str("path", r.URL.Path).Msg("MFA required but not satisfied")
|
||||
mfa.SetRequiredStatus(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !ctxHasFullPerms && (odataReq.Query.Apply != nil || odataReq.Query.Expand != nil || odataReq.Query.Compute != nil) {
|
||||
// regular users can't use filter, apply, expand and compute
|
||||
logger.Debug().Interface("query", r.URL.Query()).Msg("forbidden query elements for a regular user")
|
||||
errorcode.AccessDenied.Render(w, r, http.StatusForbidden, "query has forbidden elements for regular users")
|
||||
return
|
||||
|
||||
if !hasAcceptableQuery(odataReq.Query) {
|
||||
if !ctxHasFullPerms {
|
||||
// regular users can't use filter, apply, expand and compute
|
||||
logger.Debug().Interface("query", r.URL.Query()).Msg("forbidden query elements for a regular user")
|
||||
errorcode.AccessDenied.Render(w, r, http.StatusForbidden, "query has forbidden elements for regular users")
|
||||
return
|
||||
}
|
||||
|
||||
if !hasMFA {
|
||||
logger.Error().Str("path", r.URL.Path).Msg("MFA required but not satisfied")
|
||||
mfa.SetRequiredStatus(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debug().Interface("query", r.URL.Query()).Msg("calling get users on backend")
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"go-micro.dev/v4/client"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/mfa"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/shared"
|
||||
settingsmsg "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/settings/v0"
|
||||
settings "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
|
||||
@@ -242,7 +243,7 @@ var _ = Describe("Users", func() {
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("lists the users", func() {
|
||||
It("lists the users with 2fa", func() {
|
||||
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{
|
||||
Permission: &settingsmsg.Permission{
|
||||
Operation: settingsmsg.Permission_OPERATION_UNKNOWN,
|
||||
@@ -257,6 +258,7 @@ var _ = Describe("Users", func() {
|
||||
identityBackend.On("GetUsers", mock.Anything, mock.Anything, mock.Anything).Return(users, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/users", nil)
|
||||
r = r.WithContext(mfa.Set(r.Context(), true))
|
||||
svc.GetUsers(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
@@ -270,6 +272,19 @@ var _ = Describe("Users", func() {
|
||||
Expect(len(res.Value)).To(Equal(1))
|
||||
Expect(res.Value[0].GetId()).To(Equal("user1"))
|
||||
})
|
||||
It("denies listing without 2fa", func() {
|
||||
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{
|
||||
Permission: &settingsmsg.Permission{
|
||||
Operation: settingsmsg.Permission_OPERATION_UNKNOWN,
|
||||
Constraint: settingsmsg.Permission_CONSTRAINT_ALL,
|
||||
},
|
||||
}, nil)
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/users", nil)
|
||||
svc.GetUsers(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusForbidden))
|
||||
Expect(rr.Header().Get("X-Ocis-Mfa-Required")).To(Equal("true"))
|
||||
})
|
||||
It("denies listing for unprivileged users", func() {
|
||||
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{}, nil)
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/users", nil)
|
||||
@@ -353,6 +368,7 @@ var _ = Describe("Users", func() {
|
||||
|
||||
getUsers := func(path string) []*libregraph.User {
|
||||
r := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
r = r.WithContext(mfa.Set(r.Context(), true))
|
||||
rec := httptest.NewRecorder()
|
||||
svc.GetUsers(rec, r)
|
||||
|
||||
@@ -412,6 +428,7 @@ var _ = Describe("Users", func() {
|
||||
|
||||
// Handles invalid sort field
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/users?$orderby=invalid", nil)
|
||||
r = r.WithContext(mfa.Set(r.Context(), true))
|
||||
svc.GetUsers(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
@@ -450,6 +467,7 @@ var _ = Describe("Users", func() {
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/users?$expand=appRoleAssignments", nil)
|
||||
r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
|
||||
r = r.WithContext(mfa.Set(r.Context(), true))
|
||||
svc.GetUsers(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
@@ -490,6 +508,7 @@ var _ = Describe("Users", func() {
|
||||
}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/users?$filter="+url.QueryEscape(filter), nil)
|
||||
r = r.WithContext(mfa.Set(r.Context(), true))
|
||||
svc.GetUsers(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(status))
|
||||
@@ -550,6 +569,7 @@ var _ = Describe("Users", func() {
|
||||
}}
|
||||
}, nil)
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/users?$filter="+url.QueryEscape(filter), nil)
|
||||
r = r.WithContext(mfa.Set(r.Context(), true))
|
||||
svc.GetUsers(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(status))
|
||||
|
||||
@@ -144,14 +144,28 @@ These issued JWT tokens are immutable and integrity-protected. Which means, any
|
||||
|
||||
* Infinite Scale can't differentiate between a group being renamed in the IDP and users being reassigned to a different group.
|
||||
|
||||
* Infinite Scale does not get aware when a group is being deleted in the IDP, a new claim will not hold any information from the deleted group. Infinite Scale does not track a claim history to compare.
|
||||
* Infinite Scale does not get aware when a group is being deleted in the IDP, a new claim will not hold any information from the deleted group. Infinite Scale does not track a claim history to compare.
|
||||
|
||||
#### Claim Checks and Step-up Authentication
|
||||
|
||||
Infinite Scale provides access control via the OpenID Connect (OIDC) "Authentication Class Reference" (ACR) claim. This can be used to enforce step-up authentication on specific routes. For instance, if a user logs in with basic authentication, they may need a higher level to access a sensitive route. If the user has not authenticated at the required level, access to the route will be denied.
|
||||
This is configurable via environment variables, such as:
|
||||
|
||||
```
|
||||
OCIS_MFA_ENABLED: true
|
||||
OCIS_MFA_AUTH_LEVEL_NAME: advanced
|
||||
```
|
||||
|
||||
This feature is disabled by default and requires an external Identity Provider (IDP) that supports step-up authentication and the ACR claim. Examples of such IDPs include Keycloak.
|
||||
|
||||
If an authenticated user attempts to access a protected route without two-factor authentication (2FA), the server will respond with a 403 Forbidden error and an `X-OCIS-MFA-Required` header.
|
||||
|
||||
#### Impacts
|
||||
|
||||
For shares or space memberships based on groups, a renamed or deleted group will impact accessing the resource:
|
||||
|
||||
* There is no user notification about the inability accessing the resource.
|
||||
* The user will only experience rejected access.
|
||||
* The user will only experience rejected access.
|
||||
* This also applies for connected apps like the Desktop, iOS or Android app!
|
||||
|
||||
To give access for rejected users on a resource, one with rights to share must update the group information.
|
||||
|
||||
@@ -292,6 +292,7 @@ func loadMiddlewares(logger log.Logger, cfg *config.Config,
|
||||
RevaGatewaySelector: gatewaySelector,
|
||||
})
|
||||
}
|
||||
|
||||
authenticators = append(authenticators, middleware.NewOIDCAuthenticator(
|
||||
middleware.Logger(logger),
|
||||
middleware.UserInfoCache(userInfoCache),
|
||||
@@ -355,6 +356,7 @@ func loadMiddlewares(logger log.Logger, cfg *config.Config,
|
||||
middleware.AutoprovisionAccounts(cfg.AutoprovisionAccounts),
|
||||
middleware.EventsPublisher(publisher),
|
||||
),
|
||||
middleware.MultiFactor(cfg.MultiFactorAuthentication, middleware.Logger(logger)),
|
||||
middleware.SelectorCookie(
|
||||
middleware.Logger(logger),
|
||||
middleware.PolicySelectorConfig(*cfg.PolicySelector),
|
||||
|
||||
@@ -24,28 +24,29 @@ type Config struct {
|
||||
GRPCClientTLS *shared.GRPCClientTLS `yaml:"grpc_client_tls"`
|
||||
GrpcClient client.Client `yaml:"-"`
|
||||
|
||||
RoleQuotas map[string]uint64 `yaml:"role_quotas"`
|
||||
Policies []Policy `yaml:"policies"`
|
||||
AdditionalPolicies []Policy `yaml:"additional_policies"`
|
||||
OIDC OIDC `yaml:"oidc"`
|
||||
ServiceAccount ServiceAccount `yaml:"service_account"`
|
||||
RoleAssignment RoleAssignment `yaml:"role_assignment"`
|
||||
PolicySelector *PolicySelector `yaml:"policy_selector"`
|
||||
PreSignedURL PreSignedURL `yaml:"pre_signed_url"`
|
||||
AccountBackend string `yaml:"account_backend" env:"PROXY_ACCOUNT_BACKEND_TYPE" desc:"Account backend the PROXY service should use. Currently only 'cs3' is possible here." introductionVersion:"pre5.0"`
|
||||
UserOIDCClaim string `yaml:"user_oidc_claim" env:"PROXY_USER_OIDC_CLAIM" desc:"The name of an OpenID Connect claim that is used for resolving users with the account backend. The value of the claim must hold a per user unique, stable and non re-assignable identifier. The availability of claims depends on your Identity Provider. There are common claims available for most Identity providers like 'email' or 'preferred_username' but you can also add your own claim." introductionVersion:"pre5.0"`
|
||||
UserCS3Claim string `yaml:"user_cs3_claim" env:"PROXY_USER_CS3_CLAIM" desc:"The name of a CS3 user attribute (claim) that should be mapped to the 'user_oidc_claim'. Supported values are 'username', 'mail' and 'userid'." introductionVersion:"pre5.0"`
|
||||
MachineAuthAPIKey string `yaml:"machine_auth_api_key" env:"OCIS_MACHINE_AUTH_API_KEY;PROXY_MACHINE_AUTH_API_KEY" desc:"Machine auth API key used to validate internal requests necessary to access resources from other services." introductionVersion:"pre5.0" mask:"password"`
|
||||
AutoprovisionAccounts bool `yaml:"auto_provision_accounts" env:"PROXY_AUTOPROVISION_ACCOUNTS" desc:"Set this to 'true' to automatically provision users that do not yet exist in the users service on-demand upon first sign-in. To use this a write-enabled libregraph user backend needs to be setup an running." introductionVersion:"pre5.0"`
|
||||
AutoProvisionClaims AutoProvisionClaims `yaml:"auto_provision_claims"`
|
||||
EnableBasicAuth bool `yaml:"enable_basic_auth" env:"PROXY_ENABLE_BASIC_AUTH" desc:"Set this to true to enable 'basic authentication' (username/password)." introductionVersion:"pre5.0"`
|
||||
InsecureBackends bool `yaml:"insecure_backends" env:"PROXY_INSECURE_BACKENDS" desc:"Disable TLS certificate validation for all HTTP backend connections." introductionVersion:"pre5.0"`
|
||||
BackendHTTPSCACert string `yaml:"backend_https_cacert" env:"PROXY_HTTPS_CACERT" desc:"Path/File for the root CA certificate used to validate the server’s TLS certificate for https enabled backend services." introductionVersion:"pre5.0"`
|
||||
AuthMiddleware AuthMiddleware `yaml:"auth_middleware"`
|
||||
PoliciesMiddleware PoliciesMiddleware `yaml:"policies_middleware"`
|
||||
CSPConfigFileLocation string `yaml:"csp_config_file_location" env:"PROXY_CSP_CONFIG_FILE_LOCATION" desc:"The location of the CSP configuration file." introductionVersion:"6.0.0"`
|
||||
Events Events `yaml:"events"`
|
||||
ClaimSpaceManagement ClaimSpaceManagement `yaml:"claim_space_management"`
|
||||
RoleQuotas map[string]uint64 `yaml:"role_quotas"`
|
||||
Policies []Policy `yaml:"policies"`
|
||||
AdditionalPolicies []Policy `yaml:"additional_policies"`
|
||||
OIDC OIDC `yaml:"oidc"`
|
||||
ServiceAccount ServiceAccount `yaml:"service_account"`
|
||||
RoleAssignment RoleAssignment `yaml:"role_assignment"`
|
||||
PolicySelector *PolicySelector `yaml:"policy_selector"`
|
||||
PreSignedURL PreSignedURL `yaml:"pre_signed_url"`
|
||||
AccountBackend string `yaml:"account_backend" env:"PROXY_ACCOUNT_BACKEND_TYPE" desc:"Account backend the PROXY service should use. Currently only 'cs3' is possible here." introductionVersion:"pre5.0"`
|
||||
UserOIDCClaim string `yaml:"user_oidc_claim" env:"PROXY_USER_OIDC_CLAIM" desc:"The name of an OpenID Connect claim that is used for resolving users with the account backend. The value of the claim must hold a per user unique, stable and non re-assignable identifier. The availability of claims depends on your Identity Provider. There are common claims available for most Identity providers like 'email' or 'preferred_username' but you can also add your own claim." introductionVersion:"pre5.0"`
|
||||
UserCS3Claim string `yaml:"user_cs3_claim" env:"PROXY_USER_CS3_CLAIM" desc:"The name of a CS3 user attribute (claim) that should be mapped to the 'user_oidc_claim'. Supported values are 'username', 'mail' and 'userid'." introductionVersion:"pre5.0"`
|
||||
MachineAuthAPIKey string `yaml:"machine_auth_api_key" env:"OCIS_MACHINE_AUTH_API_KEY;PROXY_MACHINE_AUTH_API_KEY" desc:"Machine auth API key used to validate internal requests necessary to access resources from other services." introductionVersion:"pre5.0" mask:"password"`
|
||||
AutoprovisionAccounts bool `yaml:"auto_provision_accounts" env:"PROXY_AUTOPROVISION_ACCOUNTS" desc:"Set this to 'true' to automatically provision users that do not yet exist in the users service on-demand upon first sign-in. To use this a write-enabled libregraph user backend needs to be setup an running." introductionVersion:"pre5.0"`
|
||||
AutoProvisionClaims AutoProvisionClaims `yaml:"auto_provision_claims"`
|
||||
EnableBasicAuth bool `yaml:"enable_basic_auth" env:"PROXY_ENABLE_BASIC_AUTH" desc:"Set this to true to enable 'basic authentication' (username/password)." introductionVersion:"pre5.0"`
|
||||
InsecureBackends bool `yaml:"insecure_backends" env:"PROXY_INSECURE_BACKENDS" desc:"Disable TLS certificate validation for all HTTP backend connections." introductionVersion:"pre5.0"`
|
||||
BackendHTTPSCACert string `yaml:"backend_https_cacert" env:"PROXY_HTTPS_CACERT" desc:"Path/File for the root CA certificate used to validate the server’s TLS certificate for https enabled backend services." introductionVersion:"pre5.0"`
|
||||
AuthMiddleware AuthMiddleware `yaml:"auth_middleware"`
|
||||
PoliciesMiddleware PoliciesMiddleware `yaml:"policies_middleware"`
|
||||
CSPConfigFileLocation string `yaml:"csp_config_file_location" env:"PROXY_CSP_CONFIG_FILE_LOCATION" desc:"The location of the CSP configuration file." introductionVersion:"6.0.0"`
|
||||
Events Events `yaml:"events"`
|
||||
ClaimSpaceManagement ClaimSpaceManagement `yaml:"claim_space_management"`
|
||||
MultiFactorAuthentication MFAConfig `yaml:"mfa"`
|
||||
|
||||
Context context.Context `json:"-" yaml:"-"`
|
||||
}
|
||||
@@ -126,6 +127,11 @@ type JWKS struct {
|
||||
RefreshUnknownKID bool `yaml:"refresh_unknown_kid" env:"PROXY_OIDC_JWKS_REFRESH_UNKNOWN_KID" desc:"If set to 'true', the JWKS refresh request will occur every time an unknown KEY ID (KID) is seen. Always set a 'refresh_limit' when enabling this." introductionVersion:"pre5.0"`
|
||||
}
|
||||
|
||||
type MFAConfig struct {
|
||||
Enabled bool `yaml:"enabled" env:"OCIS_MFA_ENABLED" desc:"Enable MFA enforcement. If enabled users need to complete MFA before they can access specific paths" introductionVersion:"Balch"`
|
||||
AuthLevelNames []string `yaml:"auth_level_name" env:"OCIS_MFA_AUTH_LEVEL_NAMES" desc:"This authentication level name indicates that multi-factor authentication was performed. The name must match the ACR claim in the access token received. Note: If multiple names are required, use a comma-separated list. The front-end service will use the first name in the list when requesting multi-factor authentication (MFA)." introductionVersion:"Balch"`
|
||||
}
|
||||
|
||||
// Cache is a TTL cache configuration.
|
||||
type Cache struct {
|
||||
Store string `yaml:"store" env:"OCIS_CACHE_STORE;PROXY_OIDC_USERINFO_CACHE_STORE" desc:"The type of the cache store. Supported values are: 'memory', 'redis-sentinel', 'nats-js-kv', 'noop'. See the text description for details." introductionVersion:"pre5.0"`
|
||||
|
||||
@@ -100,6 +100,9 @@ func DefaultConfig() *config.Config {
|
||||
Cluster: "ocis-cluster",
|
||||
EnableTLS: false,
|
||||
},
|
||||
MultiFactorAuthentication: config.MFAConfig{
|
||||
AuthLevelNames: []string{"advanced"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
76
services/proxy/pkg/middleware/mfa.go
Normal file
76
services/proxy/pkg/middleware/mfa.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/mfa"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/oidc"
|
||||
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
|
||||
)
|
||||
|
||||
// MultiFactor returns a middleware that checks requests for mfa
|
||||
func MultiFactor(cfg config.MFAConfig, opts ...Option) func(next http.Handler) http.Handler {
|
||||
options := newOptions(opts...)
|
||||
logger := options.Logger
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return &MultiFactorAuthentication{
|
||||
next: next,
|
||||
logger: logger,
|
||||
enabled: cfg.Enabled,
|
||||
authLevelNames: cfg.AuthLevelNames,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MultiFactorAuthentication is a authenticator that checks for mfa on specific paths
|
||||
type MultiFactorAuthentication struct {
|
||||
next http.Handler
|
||||
logger log.Logger
|
||||
enabled bool
|
||||
authLevelNames []string
|
||||
}
|
||||
|
||||
// ServeHTTP adds the mfa header if the request contains a valid mfa token
|
||||
func (m MultiFactorAuthentication) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
defer m.next.ServeHTTP(w, req)
|
||||
|
||||
if !m.enabled {
|
||||
// if mfa is disabled we always set the header to true.
|
||||
// this allows all other services to assume mfa is always active.
|
||||
// this should reduce code and configuration complexity in other services.
|
||||
mfa.SetHeader(req, true)
|
||||
return
|
||||
}
|
||||
|
||||
// overwrite the mfa header to avoid passing on wrong information
|
||||
mfa.SetHeader(req, false)
|
||||
|
||||
claims := oidc.FromContext(req.Context())
|
||||
|
||||
// acr is a standard OIDC claim.
|
||||
value, err := oidc.ReadStringClaim("acr", claims)
|
||||
if err != nil {
|
||||
m.logger.Error().Str("path", req.URL.Path).Interface("required", m.authLevelNames).Err(err).Interface("claims", claims).Msg("no acr claim found in access token")
|
||||
return
|
||||
}
|
||||
|
||||
if !m.containsMFA(value) {
|
||||
m.logger.Debug().Str("acr", value).Str("url", req.URL.Path).Msg("accessing path without mfa")
|
||||
return
|
||||
}
|
||||
|
||||
mfa.SetHeader(req, true)
|
||||
m.logger.Debug().Str("acr", value).Str("url", req.URL.Path).Msg("mfa authenticated")
|
||||
}
|
||||
|
||||
// containsMFA checks if the given value is in the list of authentication level names
|
||||
func (m MultiFactorAuthentication) containsMFA(value string) bool {
|
||||
for _, v := range m.authLevelNames {
|
||||
if v == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user