Merge pull request #11603 from owncloud/oidc_claims_checker

feat: add a way to check for specific OIDC claims
This commit is contained in:
kobergj
2025-09-24 13:43:53 +02:00
committed by GitHub
20 changed files with 456 additions and 83 deletions

View File

@@ -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",

View File

@@ -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
View 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
View 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)
}
}
}

View File

@@ -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"`
}

View File

@@ -141,6 +141,9 @@ func DefaultConfig() *config.Config {
Validation: config.Validation{
MaxTagLength: 100,
},
MultiFactorAuthentication: config.MFAConfig{
AuthLevelNames: []string{"advanced"},
},
}
}

View File

@@ -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",

View File

@@ -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 == "" {

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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),

View File

@@ -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)

View File

@@ -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)

View File

@@ -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")

View File

@@ -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))

View File

@@ -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.

View File

@@ -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),

View File

@@ -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 servers 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 servers 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"`

View File

@@ -100,6 +100,9 @@ func DefaultConfig() *config.Config {
Cluster: "ocis-cluster",
EnableTLS: false,
},
MultiFactorAuthentication: config.MFAConfig{
AuthLevelNames: []string{"advanced"},
},
}
}

View 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
}