From 15d55b17f0ead88a700486f58b078d6853811683 Mon Sep 17 00:00:00 2001 From: Roman Perekhod Date: Fri, 1 Sep 2023 11:03:21 +0200 Subject: [PATCH] Add the password policies --- changelog/unreleased/add-passwod-policies.md | 5 + .../owncloud/ocs/data/capabilities.go | 25 ++- .../handlers/apps/sharing/shares/public.go | 18 +- .../handlers/apps/sharing/shares/shares.go | 45 ++++- internal/http/services/owncloud/ocs/ocs.go | 5 +- pkg/password/password_policies.go | 167 ++++++++++++++++++ pkg/password/password_policies_test.go | 86 +++++++++ 7 files changed, 336 insertions(+), 15 deletions(-) create mode 100644 changelog/unreleased/add-passwod-policies.md create mode 100644 pkg/password/password_policies.go create mode 100644 pkg/password/password_policies_test.go diff --git a/changelog/unreleased/add-passwod-policies.md b/changelog/unreleased/add-passwod-policies.md new file mode 100644 index 00000000000..df935246b7f --- /dev/null +++ b/changelog/unreleased/add-passwod-policies.md @@ -0,0 +1,5 @@ +Enhancement: Add the password policies + +Add the password policies OCIS-3767 + +https://github.com/cs3org/reva/pull/4147 diff --git a/internal/http/services/owncloud/ocs/data/capabilities.go b/internal/http/services/owncloud/ocs/data/capabilities.go index d2f5471f741..135e072f94f 100644 --- a/internal/http/services/owncloud/ocs/data/capabilities.go +++ b/internal/http/services/owncloud/ocs/data/capabilities.go @@ -50,13 +50,14 @@ type CapabilitiesData struct { // Capabilities groups several capability aspects type Capabilities struct { - Core *CapabilitiesCore `json:"core" xml:"core"` - Checksums *CapabilitiesChecksums `json:"checksums" xml:"checksums"` - Files *CapabilitiesFiles `json:"files" xml:"files" mapstructure:"files"` - Dav *CapabilitiesDav `json:"dav" xml:"dav"` - FilesSharing *CapabilitiesFilesSharing `json:"files_sharing" xml:"files_sharing" mapstructure:"files_sharing"` - Spaces *Spaces `json:"spaces,omitempty" xml:"spaces,omitempty" mapstructure:"spaces"` - Graph *CapabilitiesGraph `json:"graph,omitempty" xml:"graph,omitempty" mapstructure:"graph"` + Core *CapabilitiesCore `json:"core" xml:"core"` + Checksums *CapabilitiesChecksums `json:"checksums" xml:"checksums"` + Files *CapabilitiesFiles `json:"files" xml:"files" mapstructure:"files"` + Dav *CapabilitiesDav `json:"dav" xml:"dav"` + FilesSharing *CapabilitiesFilesSharing `json:"files_sharing" xml:"files_sharing" mapstructure:"files_sharing"` + Spaces *Spaces `json:"spaces,omitempty" xml:"spaces,omitempty" mapstructure:"spaces"` + Graph *CapabilitiesGraph `json:"graph,omitempty" xml:"graph,omitempty" mapstructure:"graph"` + PasswordPolicies *CapabilitiesPasswordPolicies `json:"password_policies,omitempty" xml:"password_policies,omitempty" mapstructure:"password_policies"` Notifications *CapabilitiesNotifications `json:"notifications,omitempty" xml:"notifications,omitempty"` } @@ -85,6 +86,16 @@ type CapabilitiesGraph struct { Users CapabilitiesGraphUsers `json:"users" xml:"users" mapstructure:"users"` } +// CapabilitiesPasswordPolicies hold the password policies capabilities +type CapabilitiesPasswordPolicies struct { + MinCharacters int `json:"min_characters" xml:"min_characters" mapstructure:"min_characters"` + MinLowerCaseCharacters int `json:"min_lower_case_characters" xml:"min_lower_case_characters" mapstructure:"min_lower_case_characters"` + MinUpperCaseCharacters int `json:"min_upper_case_characters" xml:"min_upper_case_characters" mapstructure:"min_upper_case_characters"` + MinDigits int `json:"min_digits" xml:"min_digits" mapstructure:"min_digits"` + MinSpecialCharacters int `json:"min_special_characters" xml:"min_special_characters" mapstructure:"min_special_characters"` + AllowedSpecialCharacters string `json:"allowed_special_characters" xml:"allowed_special_characters" mapstructure:"allowed_special_characters"` +} + // CapabilitiesGraphUsers holds the graph user capabilities type CapabilitiesGraphUsers struct { ReadOnlyAttributes []string `json:"read_only_attributes" xml:"read_only_attributes" mapstructure:"read_only_attributes"` diff --git a/internal/http/services/owncloud/ocs/handlers/apps/sharing/shares/public.go b/internal/http/services/owncloud/ocs/handlers/apps/sharing/shares/public.go index 907c233ead4..8c76b97461b 100644 --- a/internal/http/services/owncloud/ocs/handlers/apps/sharing/shares/public.go +++ b/internal/http/services/owncloud/ocs/handlers/apps/sharing/shares/public.go @@ -23,7 +23,6 @@ import ( "fmt" "net/http" "strconv" - "strings" permissionsv1beta1 "github.com/cs3org/go-cs3apis/cs3/permissions/v1beta1" rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" @@ -142,7 +141,7 @@ func (h *Handler) createPublicLinkShare(w http.ResponseWriter, r *http.Request, } } - password := strings.TrimSpace(r.FormValue("password")) + password := r.FormValue("password") if h.enforcePassword(permKey) && len(password) == 0 { return nil, &ocsError{ Code: response.MetaBadRequest.StatusCode, @@ -150,6 +149,13 @@ func (h *Handler) createPublicLinkShare(w http.ResponseWriter, r *http.Request, Error: errors.New("missing required password"), } } + if err := h.passwordValidator.Validate(password); len(password) > 0 && err != nil { + return nil, &ocsError{ + Code: response.MetaBadRequest.StatusCode, + Message: "password validation failed", + Error: fmt.Errorf("password validation failed: %w", err), + } + } if statInfo != nil && statInfo.Type == provider.ResourceType_RESOURCE_TYPE_FILE { // Single file shares should never have delete or create permissions @@ -460,12 +466,18 @@ func (h *Handler) updatePublicShare(w http.ResponseWriter, r *http.Request, shar newPassword, ok := r.Form["password"] // enforcePassword if h.enforcePassword(permKey) { - if (!ok && !share.PasswordProtected) || (ok && len(strings.TrimSpace(newPassword[0])) == 0) { + if !ok && !share.PasswordProtected || ok && len(newPassword[0]) == 0 { response.WriteOCSError(w, r, response.MetaBadRequest.StatusCode, "missing required password", err) return } } + // skip validation if the clear password scenario + if err := h.passwordValidator.Validate(newPassword[0]); ok && err != nil { + response.WriteOCSError(w, r, response.MetaBadRequest.StatusCode, fmt.Errorf("missing required password %w", err).Error(), err) + return + } + // update or clear password if ok { updatesFound = true diff --git a/internal/http/services/owncloud/ocs/handlers/apps/sharing/shares/shares.go b/internal/http/services/owncloud/ocs/handlers/apps/sharing/shares/shares.go index afe888047bf..df80def1ada 100644 --- a/internal/http/services/owncloud/ocs/handlers/apps/sharing/shares/shares.go +++ b/internal/http/services/owncloud/ocs/handlers/apps/sharing/shares/shares.go @@ -23,6 +23,7 @@ import ( "context" "encoding/json" "fmt" + "log" "mime" "net/http" "path" @@ -39,6 +40,7 @@ import ( link "github.com/cs3org/go-cs3apis/cs3/sharing/link/v1beta1" provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" + "github.com/cs3org/reva/v2/pkg/password" "github.com/go-chi/chi/v5" "github.com/rs/zerolog" "google.golang.org/grpc/metadata" @@ -87,6 +89,7 @@ type Handler struct { deniable bool resharing bool publicPasswordEnforced passwordEnforced + passwordValidator password.Validator getClient GatewayClientGetter } @@ -122,7 +125,8 @@ func getCacheWarmupManager(c *config.Config) (sharecache.Warmup, error) { type GatewayClientGetter func() (gateway.GatewayAPIClient, error) // Init initializes this and any contained handlers -func (h *Handler) Init(c *config.Config) { +func (h *Handler) Init(c *config.Config) error { + var err error h.gatewayAddr = c.GatewaySvc h.machineAuthAPIKey = c.MachineAuthAPIKey h.storageRegistryAddr = c.StorageregistrySvc @@ -138,20 +142,29 @@ func (h *Handler) Init(c *config.Config) { h.deniable = c.EnableDenials h.resharing = resharing(c) h.publicPasswordEnforced = publicPwdEnforced(c) + h.passwordValidator, err = passwordPolicies(c) + if err != nil { + return err + } h.statCache = cache.GetStatCache(c.StatCacheStore, c.StatCacheNodes, c.StatCacheDatabase, "stat", time.Duration(c.StatCacheTTL)*time.Second, c.StatCacheSize) if c.CacheWarmupDriver != "" { cwm, err := getCacheWarmupManager(c) - if err == nil { - go h.startCacheWarmup(cwm) + if err != nil { + return err } + go h.startCacheWarmup(cwm) } h.getClient = h.getPoolClient + return nil } // InitWithGetter initializes the handler and adds the clientGetter func (h *Handler) InitWithGetter(c *config.Config, clientGetter GatewayClientGetter) { - h.Init(c) + err := h.Init(c) + if err != nil { + log.Fatal(err) + } h.getClient = clientGetter } @@ -1581,6 +1594,30 @@ func publicPwdEnforced(c *config.Config) passwordEnforced { return enf } +func passwordPolicies(c *config.Config) (password.Validator, error) { + var pv password.Validator + var err error + if c.Capabilities.Capabilities == nil || c.Capabilities.Capabilities.PasswordPolicies == nil { + pv, err = password.NewDefaultPasswordPolicies() + if err != nil { + return nil, fmt.Errorf("can't init the Password Policies %w", err) + } + return pv, nil + } + pv, err = password.NewPasswordPolicies( + c.Capabilities.Capabilities.PasswordPolicies.MinCharacters, + c.Capabilities.Capabilities.PasswordPolicies.MinLowerCaseCharacters, + c.Capabilities.Capabilities.PasswordPolicies.MinUpperCaseCharacters, + c.Capabilities.Capabilities.PasswordPolicies.MinDigits, + c.Capabilities.Capabilities.PasswordPolicies.MinSpecialCharacters, + c.Capabilities.Capabilities.PasswordPolicies.AllowedSpecialCharacters, + ) + if err != nil { + return nil, fmt.Errorf("can't init the Password Policies %w", err) + } + return pv, nil +} + // sufficientPermissions returns true if the `existing` permissions contain the `requested` permissions func sufficientPermissions(existing, requested *provider.ResourcePermissions, islink bool) bool { ep := conversions.RoleFromResourcePermissions(existing, islink).OCSPermissions() diff --git a/internal/http/services/owncloud/ocs/ocs.go b/internal/http/services/owncloud/ocs/ocs.go index 152bddca4af..09fcfa02d21 100644 --- a/internal/http/services/owncloud/ocs/ocs.go +++ b/internal/http/services/owncloud/ocs/ocs.go @@ -104,7 +104,10 @@ func (s *svc) routerInit(log *zerolog.Logger) error { capabilitiesHandler.Init(s.c) usersHandler.Init(s.c) configHandler.Init(s.c) - sharesHandler.Init(s.c) + err := sharesHandler.Init(s.c) + if err != nil { + log.Fatal().Msg(err.Error()) + } shareesHandler.Init(s.c) s.router.Route("/v{version:(1|2)}.php", func(r chi.Router) { diff --git a/pkg/password/password_policies.go b/pkg/password/password_policies.go new file mode 100644 index 00000000000..fa0198ebc67 --- /dev/null +++ b/pkg/password/password_policies.go @@ -0,0 +1,167 @@ +package password + +import ( + "errors" + "fmt" + "regexp" + "strings" + "unicode/utf8" +) + +var defaultSpecialCharacters = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" + +type Validator interface { + Validate(str string) error +} + +type Policies struct { + minCharacters int + minLowerCaseCharacters int + minUpperCaseCharacters int + minDigits int + minSpecialCharacters int + allowedSpecialCharacters string + digits *regexp.Regexp + specialCharacters *regexp.Regexp +} + +func NewPasswordPolicies(minCharacters, minLowerCaseCharacters, minUpperCaseCharacters, minDigits, minSpecialCharacters int, + allowedSpecialCharacters string) (Validator, error) { + + digits := regexp.MustCompile("[0-9]") + if len(allowedSpecialCharacters) > 0 { + defaultSpecialCharacters = allowedSpecialCharacters + } + specialCharacters, err := regexp.Compile("[" + regexp.QuoteMeta(defaultSpecialCharacters) + "]") + if err != nil { + return nil, err + } + return &Policies{ + minCharacters: minCharacters, + minLowerCaseCharacters: minLowerCaseCharacters, + minUpperCaseCharacters: minUpperCaseCharacters, + minDigits: minDigits, + minSpecialCharacters: minSpecialCharacters, + allowedSpecialCharacters: allowedSpecialCharacters, + digits: digits, + specialCharacters: specialCharacters, + }, nil +} + +func NewDefaultPasswordPolicies() (Validator, error) { + return NewPasswordPolicies(0, 0, 0, 0, 0, "") +} + +func (s Policies) Validate(str string) error { + var allErr error + if !utf8.ValidString(str) { + return fmt.Errorf("the password contains invalid characters") + } + err := s.validateCharacters(str) + if err != nil { + allErr = errors.Join(allErr, err) + } + err = s.validateLowerCase(str) + if err != nil { + allErr = errors.Join(allErr, err) + } + err = s.validateUpperCase(str) + if err != nil { + allErr = errors.Join(allErr, err) + } + err = s.validateDigits(str) + if err != nil { + allErr = errors.Join(allErr, err) + } + err = s.validateSpecialCharacters(str) + if err != nil { + allErr = errors.Join(allErr, err) + } + if allErr != nil { + return allErr + } + return nil +} + +func (s Policies) validateCharacters(str string) error { + if s.count(str) < s.minCharacters { + if s.minCharacters == 1 { + return fmt.Errorf("at least one character is required") + } + return fmt.Errorf("at least %d characters are required", s.minCharacters) + } + return nil +} + +func (s Policies) validateLowerCase(str string) error { + if s.countLowerCaseCharacters(str) < s.minLowerCaseCharacters { + if s.minLowerCaseCharacters == 1 { + return fmt.Errorf("at least one lowercase letter is required") + } + return fmt.Errorf("at least %d lowercase letters are required", s.minLowerCaseCharacters) + } + return nil +} + +func (s Policies) validateUpperCase(str string) error { + if s.countUpperCaseCharacters(str) < s.minUpperCaseCharacters { + if s.minUpperCaseCharacters == 1 { + return fmt.Errorf("at least one uppercase letter is required") + } + return fmt.Errorf("at least %d uppercase letters are required", s.minUpperCaseCharacters) + } + return nil +} + +func (s Policies) validateDigits(str string) error { + if s.countDigits(str) < s.minDigits { + if s.minDigits == 1 { + return fmt.Errorf("at least one number is required") + } + return fmt.Errorf("at least %d numbers are required", s.minDigits) + } + return nil +} + +func (s Policies) validateSpecialCharacters(str string) error { + if s.countSpecialCharacters(str) < s.minSpecialCharacters { + if s.minSpecialCharacters == 1 { + return fmt.Errorf("at least one special character is required. %s", s.allowedSpecialCharacters) + } + return fmt.Errorf("at least %d special characters are required. %s", s.minSpecialCharacters, s.allowedSpecialCharacters) + } + return nil +} + +func (s Policies) count(str string) int { + return utf8.RuneCount([]byte(str)) +} + +func (s Policies) countLowerCaseCharacters(str string) int { + var count int + for _, c := range str { + if strings.ToLower(string(c)) == string(c) && strings.ToUpper(string(c)) != string(c) { + count++ + } + } + return count +} + +func (s Policies) countUpperCaseCharacters(str string) int { + var count int + for _, c := range str { + if strings.ToUpper(string(c)) == string(c) && strings.ToLower(string(c)) != string(c) { + count++ + } + } + return count +} + +func (s Policies) countDigits(str string) int { + return len(s.digits.FindAllStringIndex(str, -1)) +} + +func (s Policies) countSpecialCharacters(str string) int { + res := s.specialCharacters.FindAllStringIndex(str, -1) + return len(res) +} diff --git a/pkg/password/password_policies_test.go b/pkg/password/password_policies_test.go new file mode 100644 index 00000000000..12502a508c8 --- /dev/null +++ b/pkg/password/password_policies_test.go @@ -0,0 +1,86 @@ +package password + +import ( + "testing" +) + +func TestPasswordPolicies_countDigits(t *testing.T) { + type want struct { + wantCharacters int + wantLowerCaseCharacters int + wantUpperCaseCharacters int + wantDigits int + wantSpecialCharacters int + allowedSpecialCharacters string + } + tests := []struct { + name string + fields want + args string + }{ + { + name: "all in one", + fields: want{ + wantCharacters: 100, + wantLowerCaseCharacters: 29, + wantUpperCaseCharacters: 29, + wantDigits: 10, + wantSpecialCharacters: 32, + }, + args: "1234567890abcdefghijklmnopqrstuvwxyzäöüABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÜ!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~", + }, + { + name: "length only", + fields: want{ + wantCharacters: 4, + wantLowerCaseCharacters: 0, + wantUpperCaseCharacters: 0, + wantDigits: 0, + wantSpecialCharacters: 0, + }, + args: "世界 ß", + }, + { + name: "empty", + fields: want{ + wantCharacters: 0, + wantLowerCaseCharacters: 0, + wantUpperCaseCharacters: 0, + wantDigits: 0, + wantSpecialCharacters: 0, + }, + args: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + i, err := NewPasswordPolicies( + tt.fields.wantCharacters, + tt.fields.wantLowerCaseCharacters, + tt.fields.wantUpperCaseCharacters, + tt.fields.wantDigits, + tt.fields.wantSpecialCharacters, + tt.fields.allowedSpecialCharacters, + ) + if err != nil { + t.Error(err) + } + s := i.(*Policies) + if got := s.count(tt.args); got != tt.fields.wantCharacters { + t.Errorf("count() = %v, want %v", got, tt.fields.wantCharacters) + } + if got := s.countLowerCaseCharacters(tt.args); got != tt.fields.wantLowerCaseCharacters { + t.Errorf("countLowerCaseCharacters() = %v, want %v", got, tt.fields.wantLowerCaseCharacters) + } + if got := s.countUpperCaseCharacters(tt.args); got != tt.fields.wantUpperCaseCharacters { + t.Errorf("countUpperCaseCharacters() = %v, want %v", got, tt.fields.wantUpperCaseCharacters) + } + if got := s.countDigits(tt.args); got != tt.fields.wantDigits { + t.Errorf("countDigits() = %v, want %v", got, tt.fields.wantDigits) + } + if got := s.countSpecialCharacters(tt.args); got != tt.fields.wantSpecialCharacters { + t.Errorf("countSpecialCharacters() = %v, want %v", got, tt.fields.wantSpecialCharacters) + } + }) + } +}