diff --git a/Dockerfile.revad-ceph b/Dockerfile.revad-ceph index fb6113d725c..faab9e7d685 100644 --- a/Dockerfile.revad-ceph +++ b/Dockerfile.revad-ceph @@ -26,12 +26,12 @@ RUN dnf update --exclude=ceph-iscsi -y && dnf install -y \ librbd-devel \ librados-devel -ADD https://golang.org/dl/go1.19.linux-amd64.tar.gz \ - go1.19.linux-amd64.tar.gz +ADD https://golang.org/dl/go1.20.linux-amd64.tar.gz \ + go1.20.linux-amd64.tar.gz RUN rm -rf /usr/local/go && \ - tar -C /usr/local -xzf go1.19.linux-amd64.tar.gz && \ - rm go1.19.linux-amd64.tar.gz + tar -C /usr/local -xzf go1.20.linux-amd64.tar.gz && \ + rm go1.20.linux-amd64.tar.gz ENV PATH /go/bin:/usr/local/go/bin:$PATH ENV GOPATH /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..bdb96e3d903 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,15 @@ func (h *Handler) createPublicLinkShare(w http.ResponseWriter, r *http.Request, Error: errors.New("missing required password"), } } + if len(password) > 0 { + if err := h.passwordValidator.Validate(password); 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,7 +468,7 @@ 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 } @@ -468,6 +476,13 @@ func (h *Handler) updatePublicShare(w http.ResponseWriter, r *http.Request, shar // update or clear password if ok { + // skip validation if the clear password scenario + if len(newPassword[0]) > 0 { + if err := h.passwordValidator.Validate(newPassword[0]); err != nil { + response.WriteOCSError(w, r, response.MetaBadRequest.StatusCode, fmt.Errorf("missing required password %w", err).Error(), err) + return + } + } updatesFound = true logger.Info().Str("shares", "update").Msg("password updated") updates = append(updates, &link.UpdatePublicShareRequest_Update{ 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..1ae474339a6 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.NewPasswordPolicies(0, 0, 0, 0, 0, "") + 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..29e6d0af181 --- /dev/null +++ b/pkg/password/password_policies.go @@ -0,0 +1,152 @@ +package password + +import ( + "errors" + "fmt" + "regexp" + "strings" + "unicode/utf8" +) + +var _defaultSpecialCharacters = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" + +// Validator describes the interface providing a password Validate method +type Validator interface { + Validate(str string) error +} + +// Policies represents a password validation rules +type Policies struct { + minCharacters int + minLowerCaseCharacters int + minUpperCaseCharacters int + minDigits int + minSpecialCharacters int + allowedSpecialCharacters string + digits *regexp.Regexp + specialCharacters *regexp.Regexp +} + +// NewPasswordPolicies returns a new NewPasswordPolicies instance +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 +} + +// Validate implements a password validation regarding the policy +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 { + 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 { + 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 { + 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 { + 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 { + 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) + } + }) + } +}