// Copyright (C) 2019-2023 Nicola Murino // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, version 3. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package dataprovider import ( "encoding/json" "fmt" "net" "strings" "time" "github.com/alexedwards/argon2id" passwordvalidator "github.com/wagslane/go-password-validator" "golang.org/x/crypto/bcrypt" "github.com/drakkan/sftpgo/v2/internal/logger" "github.com/drakkan/sftpgo/v2/internal/util" ) // ShareScope defines the supported share scopes type ShareScope int // Supported share scopes const ( ShareScopeRead ShareScope = iota + 1 ShareScopeWrite ShareScopeReadWrite ) const ( redactedPassword = "[**redacted**]" ) // Share defines files and or directories shared with external users type Share struct { // Database unique identifier ID int64 `json:"-"` // Unique ID used to access this object ShareID string `json:"id"` Name string `json:"name"` Description string `json:"description,omitempty"` Scope ShareScope `json:"scope"` // Paths to files or directories, for ShareScopeWrite it must be exactly one directory Paths []string `json:"paths"` // Username who shared this object Username string `json:"username"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` // 0 means never used LastUseAt int64 `json:"last_use_at,omitempty"` // ExpiresAt expiration date/time as unix timestamp in milliseconds, 0 means no expiration ExpiresAt int64 `json:"expires_at,omitempty"` // Optional password to protect the share Password string `json:"password"` // Limit the available access tokens, 0 means no limit MaxTokens int `json:"max_tokens,omitempty"` // Used tokens UsedTokens int `json:"used_tokens,omitempty"` // Limit the share availability to these IPs/CIDR networks AllowFrom []string `json:"allow_from,omitempty"` // set for restores, we don't have to validate the expiration date // otherwise we fail to restore existing shares and we have to insert // all the previous values with no modifications IsRestore bool `json:"-"` } // GetScopeAsString returns the share's scope as string. // Used in web pages func (s *Share) GetScopeAsString() string { switch s.Scope { case ShareScopeWrite: return "Write" case ShareScopeReadWrite: return "Read/Write" default: return "Read" } } // IsExpired returns true if the share is expired func (s *Share) IsExpired() bool { if s.ExpiresAt > 0 { return s.ExpiresAt < util.GetTimeAsMsSinceEpoch(time.Now()) } return false } // GetInfoString returns share's info as string. func (s *Share) GetInfoString() string { var result strings.Builder if s.ExpiresAt > 0 { t := util.GetTimeFromMsecSinceEpoch(s.ExpiresAt) result.WriteString(fmt.Sprintf("Expiration: %v. ", t.Format("2006-01-02 15:04"))) // YYYY-MM-DD HH:MM } if s.LastUseAt > 0 { t := util.GetTimeFromMsecSinceEpoch(s.LastUseAt) result.WriteString(fmt.Sprintf("Last use: %v. ", t.Format("2006-01-02 15:04"))) } if s.MaxTokens > 0 { result.WriteString(fmt.Sprintf("Usage: %v/%v. ", s.UsedTokens, s.MaxTokens)) } else { result.WriteString(fmt.Sprintf("Used tokens: %v. ", s.UsedTokens)) } if len(s.AllowFrom) > 0 { result.WriteString(fmt.Sprintf("Allowed IP/Mask: %v. ", len(s.AllowFrom))) } if s.Password != "" { result.WriteString("Password protected.") } return result.String() } // GetAllowedFromAsString returns the allowed IP as comma separated string func (s *Share) GetAllowedFromAsString() string { return strings.Join(s.AllowFrom, ",") } func (s *Share) getACopy() Share { allowFrom := make([]string, len(s.AllowFrom)) copy(allowFrom, s.AllowFrom) return Share{ ID: s.ID, ShareID: s.ShareID, Name: s.Name, Description: s.Description, Scope: s.Scope, Paths: s.Paths, Username: s.Username, CreatedAt: s.CreatedAt, UpdatedAt: s.UpdatedAt, LastUseAt: s.LastUseAt, ExpiresAt: s.ExpiresAt, Password: s.Password, MaxTokens: s.MaxTokens, UsedTokens: s.UsedTokens, AllowFrom: allowFrom, } } // RenderAsJSON implements the renderer interface used within plugins func (s *Share) RenderAsJSON(reload bool) ([]byte, error) { if reload { share, err := provider.shareExists(s.ShareID, s.Username) if err != nil { providerLog(logger.LevelError, "unable to reload share before rendering as json: %v", err) return nil, err } share.HideConfidentialData() return json.Marshal(share) } s.HideConfidentialData() return json.Marshal(s) } // HideConfidentialData hides share confidential data func (s *Share) HideConfidentialData() { if s.Password != "" { s.Password = redactedPassword } } // HasRedactedPassword returns true if this share has a redacted password func (s *Share) HasRedactedPassword() bool { return s.Password == redactedPassword } func (s *Share) hashPassword() error { if s.Password != "" && !util.IsStringPrefixInSlice(s.Password, internalHashPwdPrefixes) { user, err := UserExists(s.Username, "") if err != nil { return util.NewGenericError(fmt.Sprintf("unable to validate user: %v", err)) } if minEntropy := user.getMinPasswordEntropy(); minEntropy > 0 { if err := passwordvalidator.Validate(s.Password, minEntropy); err != nil { return util.NewValidationError(err.Error()) } } if config.PasswordHashing.Algo == HashingAlgoBcrypt { hashed, err := bcrypt.GenerateFromPassword([]byte(s.Password), config.PasswordHashing.BcryptOptions.Cost) if err != nil { return err } s.Password = string(hashed) } else { hashed, err := argon2id.CreateHash(s.Password, argon2Params) if err != nil { return err } s.Password = hashed } } return nil } func (s *Share) validatePaths() error { var paths []string for _, p := range s.Paths { if strings.TrimSpace(p) != "" { paths = append(paths, p) } } s.Paths = paths if len(s.Paths) == 0 { return util.NewValidationError("at least a shared path is required") } for idx := range s.Paths { s.Paths[idx] = util.CleanPath(s.Paths[idx]) } s.Paths = util.RemoveDuplicates(s.Paths, false) if s.Scope >= ShareScopeWrite && len(s.Paths) != 1 { return util.NewValidationError("the write share scope requires exactly one path") } // check nested paths if len(s.Paths) > 1 { for idx := range s.Paths { for innerIdx := range s.Paths { if idx == innerIdx { continue } if util.IsDirOverlapped(s.Paths[idx], s.Paths[innerIdx], true, "/") { return util.NewGenericError("shared paths cannot be nested") } } } } return nil } func (s *Share) validate() error { if s.ShareID == "" { return util.NewValidationError("share_id is mandatory") } if s.Name == "" { return util.NewValidationError("name is mandatory") } if s.Scope < ShareScopeRead || s.Scope > ShareScopeReadWrite { return util.NewValidationError(fmt.Sprintf("invalid scope: %v", s.Scope)) } if err := s.validatePaths(); err != nil { return err } if s.ExpiresAt > 0 { if !s.IsRestore && s.ExpiresAt < util.GetTimeAsMsSinceEpoch(time.Now()) { return util.NewValidationError("expiration must be in the future") } } else { s.ExpiresAt = 0 } if s.MaxTokens < 0 { return util.NewValidationError("invalid max tokens") } if s.Username == "" { return util.NewValidationError("username is mandatory") } if s.HasRedactedPassword() { return util.NewValidationError("cannot save a share with a redacted password") } if err := s.hashPassword(); err != nil { return err } s.AllowFrom = util.RemoveDuplicates(s.AllowFrom, false) for _, IPMask := range s.AllowFrom { _, _, err := net.ParseCIDR(IPMask) if err != nil { return util.NewValidationError(fmt.Sprintf("could not parse allow from entry %q : %v", IPMask, err)) } } return nil } // CheckCredentials verifies the share credentials if a password if set func (s *Share) CheckCredentials(password string) (bool, error) { if s.Password == "" { return true, nil } if password == "" { return false, ErrInvalidCredentials } if strings.HasPrefix(s.Password, bcryptPwdPrefix) { if err := bcrypt.CompareHashAndPassword([]byte(s.Password), []byte(password)); err != nil { return false, ErrInvalidCredentials } return true, nil } match, err := argon2id.ComparePasswordAndHash(password, s.Password) if !match || err != nil { return false, ErrInvalidCredentials } return match, err } // GetRelativePath returns the specified absolute path as relative to the share base path func (s *Share) GetRelativePath(name string) string { if len(s.Paths) == 0 { return "" } return util.CleanPath(strings.TrimPrefix(name, s.Paths[0])) } // IsUsable checks if the share is usable from the specified IP func (s *Share) IsUsable(ip string) (bool, error) { if s.MaxTokens > 0 && s.UsedTokens >= s.MaxTokens { return false, util.NewRecordNotFoundError("max share usage exceeded") } if s.ExpiresAt > 0 { if s.ExpiresAt < util.GetTimeAsMsSinceEpoch(time.Now()) { return false, util.NewRecordNotFoundError("share expired") } } if len(s.AllowFrom) == 0 { return true, nil } parsedIP := net.ParseIP(ip) if parsedIP == nil { return false, ErrLoginNotAllowedFromIP } for _, ipMask := range s.AllowFrom { _, network, err := net.ParseCIDR(ipMask) if err != nil { continue } if network.Contains(parsedIP) { return true, nil } } return false, ErrLoginNotAllowedFromIP }