Skip to content

Commit 2f1ab88

Browse files
authored
Merge pull request #11603 from owncloud/oidc_claims_checker
feat: add a way to check for specific OIDC claims
2 parents f062482 + 04b829e commit 2f1ab88

File tree

20 files changed

+456
-83
lines changed

20 files changed

+456
-83
lines changed

deployments/examples/ocis_keycloak/config/keycloak/ocis-realm.dist.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1877,7 +1877,7 @@
18771877
"description": "OpenID Connect scope for add acr (authentication context class reference) to the token",
18781878
"protocol": "openid-connect",
18791879
"attributes": {
1880-
"include.in.token.scope": "false",
1880+
"include.in.token.scope": "true",
18811881
"display.on.consent.screen": "false"
18821882
},
18831883
"protocolMappers": [
@@ -2899,7 +2899,7 @@
28992899
"config": {}
29002900
}
29012901
],
2902-
"browserFlow": "browser",
2902+
"browserFlow": "step up flow",
29032903
"registrationFlow": "registration",
29042904
"directGrantFlow": "direct grant",
29052905
"resetCredentialsFlow": "reset credentials",

deployments/examples/ocis_keycloak/docker-compose.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ services:
8181
OCIS_PASSWORD_POLICY_BANNED_PASSWORDS_LIST: "banned-password-list.txt"
8282
PROXY_CSP_CONFIG_FILE_LOCATION: /etc/ocis/csp.yaml
8383
KEYCLOAK_DOMAIN: ${KEYCLOAK_DOMAIN:-keycloak.owncloud.test}
84+
OCIS_MFA_ENABLED: ${OCIS_MFA_ENABLED:-false}
85+
WEB_OIDC_SCOPE: "openid profile email acr"
8486
volumes:
8587
- ./config/ocis/banned-password-list.txt:/etc/ocis/banned-password-list.txt
8688
- ./config/ocis/csp.yaml:/etc/ocis/csp.yaml

ocis-pkg/mfa/mfa.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Package mfa provides functionality for multi-factor authentication (MFA).
2+
// mfa_test.go contains usage examples and tests.
3+
package mfa
4+
5+
import (
6+
"context"
7+
"net/http"
8+
)
9+
10+
// MFAHeader is the header to be used across grpc and http services
11+
// to forward the access token.
12+
const MFAHeader = "X-Multi-Factor-Authentication"
13+
14+
// MFARequiredHeader is the header returned by the server if step-up authentication is required.
15+
const MFARequiredHeader = "X-Ocis-Mfa-Required"
16+
17+
type mfaKeyType struct{}
18+
19+
var mfaKey = mfaKeyType{}
20+
21+
// EnhanceRequest enhances the request context with the MFA status from the header.
22+
// This operation does not overwrite existing context values.
23+
func EnhanceRequest(req *http.Request) *http.Request {
24+
ctx := req.Context()
25+
if Has(ctx) {
26+
return req
27+
}
28+
return req.WithContext(Set(ctx, req.Header.Get(MFAHeader) == "true"))
29+
}
30+
31+
// SetRequiredStatus sets the MFA required header and the statuscode to 403
32+
func SetRequiredStatus(w http.ResponseWriter) {
33+
w.Header().Set(MFARequiredHeader, "true")
34+
w.WriteHeader(http.StatusForbidden)
35+
}
36+
37+
// Has returns the mfa status from the context.
38+
func Has(ctx context.Context) bool {
39+
mfa, ok := ctx.Value(mfaKey).(bool)
40+
if !ok {
41+
return false
42+
}
43+
return mfa
44+
}
45+
46+
// Set stores the mfa status in the context.
47+
func Set(ctx context.Context, mfa bool) context.Context {
48+
return context.WithValue(ctx, mfaKey, mfa)
49+
}
50+
51+
// SetHeader sets the MFA header.
52+
func SetHeader(r *http.Request, mfa bool) {
53+
if mfa {
54+
r.Header.Set(MFAHeader, "true")
55+
return
56+
}
57+
58+
r.Header.Set(MFAHeader, "false")
59+
}

ocis-pkg/mfa/mfa_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package mfa_test
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/owncloud/ocis/v2/ocis-pkg/mfa"
9+
"github.com/test-go/testify/require"
10+
)
11+
12+
func exampleUsage() http.HandlerFunc {
13+
return func(w http.ResponseWriter, r *http.Request) {
14+
// In a central place of your service enhance request once.
15+
// Note: This will not overwrite existing context values so it's safe (but unnecessary) to call multiple times.
16+
r = mfa.EnhanceRequest(r)
17+
18+
// somewhere in your code extract the context
19+
ctx := r.Context()
20+
21+
// now you can check if the user has MFA enabled
22+
if !mfa.Has(ctx) {
23+
// use this line to log access denied information
24+
// mfa package will not log anything by itself
25+
mfa.SetRequiredStatus(w)
26+
return
27+
}
28+
// user has MFA enabled, you can now proceed with sensitive operation
29+
}
30+
}
31+
32+
func TestMFALifecycle(t *testing.T) {
33+
testCases := []struct {
34+
Alias string
35+
HasMFA bool
36+
ShouldHaveMFA bool
37+
ResponseCode int
38+
}{
39+
{
40+
Alias: "simple",
41+
HasMFA: true,
42+
ResponseCode: http.StatusOK,
43+
},
44+
{
45+
Alias: "denied",
46+
HasMFA: false,
47+
ResponseCode: http.StatusForbidden,
48+
},
49+
}
50+
51+
for _, tc := range testCases {
52+
w := httptest.NewRecorder()
53+
r := httptest.NewRequest("GET", "http://url&method.doesnt.matter", nil)
54+
mfa.SetHeader(r, tc.HasMFA)
55+
56+
exampleUsage().ServeHTTP(w, r)
57+
res := w.Result()
58+
59+
require.Equal(t, tc.ResponseCode, res.StatusCode, tc.Alias)
60+
if tc.ResponseCode == http.StatusForbidden {
61+
require.Equal(t, "true", res.Header.Get(mfa.MFARequiredHeader), tc.Alias)
62+
} else {
63+
require.Empty(t, res.Header.Get(mfa.MFARequiredHeader), tc.Alias)
64+
}
65+
66+
}
67+
68+
}

services/frontend/pkg/config/config.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ type Config struct {
6363

6464
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"`
6565

66+
MultiFactorAuthentication MFAConfig `yaml:"mfa"`
67+
6668
Context context.Context `yaml:"-"`
6769
}
6870

@@ -200,3 +202,9 @@ type PasswordPolicy struct {
200202
type Validation struct {
201203
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"`
202204
}
205+
206+
// MFAConfig configures multi factor multifactor authentication
207+
type MFAConfig struct {
208+
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"`
209+
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"`
210+
}

services/frontend/pkg/config/defaults/defaultconfig.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ func DefaultConfig() *config.Config {
141141
Validation: config.Validation{
142142
MaxTagLength: 100,
143143
},
144+
MultiFactorAuthentication: config.MFAConfig{
145+
AuthLevelNames: []string{"advanced"},
146+
},
144147
}
145148
}
146149

services/frontend/pkg/revaconfig/config.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,12 @@ func FrontendConfigFromStruct(cfg *config.Config, logger log.Logger) (map[string
340340
"endpoints": []string{"list", "get", "delete"},
341341
"configurable": cfg.ConfigurableNotifications,
342342
},
343+
"auth": map[string]interface{}{
344+
"mfa": map[string]interface{}{
345+
"enabled": cfg.MultiFactorAuthentication.Enabled,
346+
"levelnames": cfg.MultiFactorAuthentication.AuthLevelNames,
347+
},
348+
},
343349
},
344350
"version": map[string]interface{}{
345351
"product": "Infinite Scale",

services/graph/pkg/middleware/auth.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"github.com/owncloud/ocis/v2/ocis-pkg/account"
1010
"github.com/owncloud/ocis/v2/ocis-pkg/log"
11+
"github.com/owncloud/ocis/v2/ocis-pkg/mfa"
1112
opkgm "github.com/owncloud/ocis/v2/ocis-pkg/middleware"
1213
"github.com/owncloud/ocis/v2/services/graph/pkg/errorcode"
1314
"github.com/owncloud/reva/v2/pkg/auth/scope"
@@ -42,6 +43,8 @@ func Auth(opts ...account.Option) func(http.Handler) http.Handler {
4243
}
4344
return func(next http.Handler) http.Handler {
4445
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
46+
r = mfa.EnhanceRequest(r)
47+
4548
ctx := r.Context()
4649
t := r.Header.Get("x-access-token")
4750
if t == "" {

services/graph/pkg/service/v0/drives.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929
"google.golang.org/protobuf/proto"
3030

3131
"github.com/owncloud/ocis/v2/ocis-pkg/l10n"
32+
"github.com/owncloud/ocis/v2/ocis-pkg/mfa"
3233
v0 "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/settings/v0"
3334
settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
3435
"github.com/owncloud/ocis/v2/services/graph/pkg/errorcode"
@@ -132,6 +133,13 @@ func (g Graph) GetAllDrives(version APIVersion) http.HandlerFunc {
132133
// GetAllDrivesV1 attempts to retrieve the current users drives;
133134
// it includes another user's drives, if the current user has the permission.
134135
func (g Graph) GetAllDrivesV1(w http.ResponseWriter, r *http.Request) {
136+
if !mfa.Has(r.Context()) {
137+
logger := g.logger.SubloggerWithRequestID(r.Context())
138+
logger.Error().Str("path", r.URL.Path).Msg("MFA required but not satisfied")
139+
mfa.SetRequiredStatus(w)
140+
return
141+
}
142+
135143
spaces, errCode := g.getDrives(r, true, APIVersion_1)
136144
if errCode != nil {
137145
errorcode.RenderError(w, r, errCode)
@@ -152,6 +160,13 @@ func (g Graph) GetAllDrivesV1(w http.ResponseWriter, r *http.Request) {
152160
// it includes the grantedtoV2 property
153161
// it uses unified roles instead of the cs3 representations
154162
func (g Graph) GetAllDrivesV1Beta1(w http.ResponseWriter, r *http.Request) {
163+
if !mfa.Has(r.Context()) {
164+
logger := g.logger.SubloggerWithRequestID(r.Context())
165+
logger.Error().Str("path", r.URL.Path).Msg("MFA required but not satisfied")
166+
mfa.SetRequiredStatus(w)
167+
return
168+
}
169+
155170
drives, errCode := g.getDrives(r, true, APIVersion_1_Beta_1)
156171
if errCode != nil {
157172
errorcode.RenderError(w, r, errCode)

services/graph/pkg/service/v0/graph.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"path"
99
"strings"
1010

11+
"github.com/CiscoM31/godata"
1112
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
1213
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
1314
"github.com/go-chi/chi/v5"
@@ -152,3 +153,39 @@ func parseIDParam(r *http.Request, param string) (storageprovider.ResourceId, er
152153
}
153154
return id, nil
154155
}
156+
157+
// regular users can only search for terms with a minimum length
158+
func hasAcceptableSearch(query *godata.GoDataQuery, minSearchLength int) bool {
159+
if query == nil || query.Search == nil {
160+
return false
161+
}
162+
163+
if strings.HasPrefix(query.Search.RawValue, "\"") {
164+
// if search starts with double quotes then it must finish with double quotes
165+
// add +2 to the minimum search length in this case
166+
minSearchLength += 2
167+
}
168+
169+
return len(query.Search.RawValue) >= minSearchLength
170+
}
171+
172+
// regular users can only filter by userType
173+
func hasAcceptableFilter(query *godata.GoDataQuery) bool {
174+
switch {
175+
case query == nil || query.Filter == nil:
176+
return true
177+
case query.Filter.Tree.Token.Type != godata.ExpressionTokenLogical:
178+
return false
179+
case query.Filter.Tree.Token.Value != "eq":
180+
return false
181+
case query.Filter.Tree.Children[0].Token.Value != "userType":
182+
return false
183+
}
184+
185+
return true
186+
}
187+
188+
// regular users can only use basic queries without any expansions, computes or applies
189+
func hasAcceptableQuery(query *godata.GoDataQuery) bool {
190+
return query != nil && query.Apply == nil && query.Expand == nil && query.Compute == nil
191+
}

0 commit comments

Comments
 (0)