1package dataprovider 2 3import ( 4 "encoding/json" 5 "fmt" 6 "net" 7 "strings" 8 "time" 9 10 "github.com/alexedwards/argon2id" 11 "golang.org/x/crypto/bcrypt" 12 13 "github.com/drakkan/sftpgo/v2/logger" 14 "github.com/drakkan/sftpgo/v2/util" 15) 16 17// ShareScope defines the supported share scopes 18type ShareScope int 19 20// Supported share scopes 21const ( 22 ShareScopeRead ShareScope = iota + 1 23 ShareScopeWrite 24) 25 26const ( 27 redactedPassword = "[**redacted**]" 28) 29 30// Share defines files and or directories shared with external users 31type Share struct { 32 // Database unique identifier 33 ID int64 `json:"-"` 34 // Unique ID used to access this object 35 ShareID string `json:"id"` 36 Name string `json:"name"` 37 Description string `json:"description,omitempty"` 38 Scope ShareScope `json:"scope"` 39 // Paths to files or directories, for ShareScopeWrite it must be exactly one directory 40 Paths []string `json:"paths"` 41 // Username who shared this object 42 Username string `json:"username"` 43 CreatedAt int64 `json:"created_at"` 44 UpdatedAt int64 `json:"updated_at"` 45 // 0 means never used 46 LastUseAt int64 `json:"last_use_at,omitempty"` 47 // ExpiresAt expiration date/time as unix timestamp in milliseconds, 0 means no expiration 48 ExpiresAt int64 `json:"expires_at,omitempty"` 49 // Optional password to protect the share 50 Password string `json:"password"` 51 // Limit the available access tokens, 0 means no limit 52 MaxTokens int `json:"max_tokens,omitempty"` 53 // Used tokens 54 UsedTokens int `json:"used_tokens,omitempty"` 55 // Limit the share availability to these IPs/CIDR networks 56 AllowFrom []string `json:"allow_from,omitempty"` 57 // set for restores, we don't have to validate the expiration date 58 // otherwise we fail to restore existing shares and we have to insert 59 // all the previous values with no modifications 60 IsRestore bool `json:"-"` 61} 62 63// GetScopeAsString returns the share's scope as string. 64// Used in web pages 65func (s *Share) GetScopeAsString() string { 66 switch s.Scope { 67 case ShareScopeRead: 68 return "Read" 69 default: 70 return "Write" 71 } 72} 73 74// IsExpired returns true if the share is expired 75func (s *Share) IsExpired() bool { 76 if s.ExpiresAt > 0 { 77 return s.ExpiresAt < util.GetTimeAsMsSinceEpoch(time.Now()) 78 } 79 return false 80} 81 82// GetInfoString returns share's info as string. 83func (s *Share) GetInfoString() string { 84 var result strings.Builder 85 if s.ExpiresAt > 0 { 86 t := util.GetTimeFromMsecSinceEpoch(s.ExpiresAt) 87 result.WriteString(fmt.Sprintf("Expiration: %v. ", t.Format("2006-01-02 15:04"))) // YYYY-MM-DD HH:MM 88 } 89 if s.LastUseAt > 0 { 90 t := util.GetTimeFromMsecSinceEpoch(s.LastUseAt) 91 result.WriteString(fmt.Sprintf("Last use: %v. ", t.Format("2006-01-02 15:04"))) 92 } 93 if s.MaxTokens > 0 { 94 result.WriteString(fmt.Sprintf("Usage: %v/%v. ", s.UsedTokens, s.MaxTokens)) 95 } else { 96 result.WriteString(fmt.Sprintf("Used tokens: %v. ", s.UsedTokens)) 97 } 98 if len(s.AllowFrom) > 0 { 99 result.WriteString(fmt.Sprintf("Allowed IP/Mask: %v. ", len(s.AllowFrom))) 100 } 101 if s.Password != "" { 102 result.WriteString("Password protected.") 103 } 104 return result.String() 105} 106 107// GetAllowedFromAsString returns the allowed IP as comma separated string 108func (s *Share) GetAllowedFromAsString() string { 109 return strings.Join(s.AllowFrom, ",") 110} 111 112func (s *Share) getACopy() Share { 113 allowFrom := make([]string, len(s.AllowFrom)) 114 copy(allowFrom, s.AllowFrom) 115 116 return Share{ 117 ID: s.ID, 118 ShareID: s.ShareID, 119 Name: s.Name, 120 Description: s.Description, 121 Scope: s.Scope, 122 Paths: s.Paths, 123 Username: s.Username, 124 CreatedAt: s.CreatedAt, 125 UpdatedAt: s.UpdatedAt, 126 LastUseAt: s.LastUseAt, 127 ExpiresAt: s.ExpiresAt, 128 Password: s.Password, 129 MaxTokens: s.MaxTokens, 130 UsedTokens: s.UsedTokens, 131 AllowFrom: allowFrom, 132 } 133} 134 135// RenderAsJSON implements the renderer interface used within plugins 136func (s *Share) RenderAsJSON(reload bool) ([]byte, error) { 137 if reload { 138 share, err := provider.shareExists(s.ShareID, s.Username) 139 if err != nil { 140 providerLog(logger.LevelWarn, "unable to reload share before rendering as json: %v", err) 141 return nil, err 142 } 143 share.HideConfidentialData() 144 return json.Marshal(share) 145 } 146 s.HideConfidentialData() 147 return json.Marshal(s) 148} 149 150// HideConfidentialData hides share confidential data 151func (s *Share) HideConfidentialData() { 152 if s.Password != "" { 153 s.Password = redactedPassword 154 } 155} 156 157// HasRedactedPassword returns true if this share has a redacted password 158func (s *Share) HasRedactedPassword() bool { 159 return s.Password == redactedPassword 160} 161 162func (s *Share) hashPassword() error { 163 if s.Password != "" && !util.IsStringPrefixInSlice(s.Password, internalHashPwdPrefixes) { 164 if config.PasswordHashing.Algo == HashingAlgoBcrypt { 165 hashed, err := bcrypt.GenerateFromPassword([]byte(s.Password), config.PasswordHashing.BcryptOptions.Cost) 166 if err != nil { 167 return err 168 } 169 s.Password = string(hashed) 170 } else { 171 hashed, err := argon2id.CreateHash(s.Password, argon2Params) 172 if err != nil { 173 return err 174 } 175 s.Password = hashed 176 } 177 } 178 return nil 179} 180 181func (s *Share) validatePaths() error { 182 var paths []string 183 for _, p := range s.Paths { 184 p = strings.TrimSpace(p) 185 if p != "" { 186 paths = append(paths, p) 187 } 188 } 189 s.Paths = paths 190 if len(s.Paths) == 0 { 191 return util.NewValidationError("at least a shared path is required") 192 } 193 for idx := range s.Paths { 194 s.Paths[idx] = util.CleanPath(s.Paths[idx]) 195 } 196 s.Paths = util.RemoveDuplicates(s.Paths) 197 if s.Scope == ShareScopeWrite && len(s.Paths) != 1 { 198 return util.NewValidationError("the write share scope requires exactly one path") 199 } 200 return nil 201} 202 203func (s *Share) validate() error { 204 if s.ShareID == "" { 205 return util.NewValidationError("share_id is mandatory") 206 } 207 if s.Name == "" { 208 return util.NewValidationError("name is mandatory") 209 } 210 if s.Scope != ShareScopeRead && s.Scope != ShareScopeWrite { 211 return util.NewValidationError(fmt.Sprintf("invalid scope: %v", s.Scope)) 212 } 213 if err := s.validatePaths(); err != nil { 214 return err 215 } 216 if s.ExpiresAt > 0 { 217 if !s.IsRestore && s.ExpiresAt < util.GetTimeAsMsSinceEpoch(time.Now()) { 218 return util.NewValidationError("expiration must be in the future") 219 } 220 } else { 221 s.ExpiresAt = 0 222 } 223 if s.MaxTokens < 0 { 224 return util.NewValidationError("invalid max tokens") 225 } 226 if s.Username == "" { 227 return util.NewValidationError("username is mandatory") 228 } 229 if s.HasRedactedPassword() { 230 return util.NewValidationError("cannot save a share with a redacted password") 231 } 232 if err := s.hashPassword(); err != nil { 233 return err 234 } 235 s.AllowFrom = util.RemoveDuplicates(s.AllowFrom) 236 for _, IPMask := range s.AllowFrom { 237 _, _, err := net.ParseCIDR(IPMask) 238 if err != nil { 239 return util.NewValidationError(fmt.Sprintf("could not parse allow from entry %#v : %v", IPMask, err)) 240 } 241 } 242 return nil 243} 244 245// CheckPassword verifies the share password if set 246func (s *Share) CheckPassword(password string) (bool, error) { 247 if s.Password == "" { 248 return true, nil 249 } 250 if password == "" { 251 return false, ErrInvalidCredentials 252 } 253 if strings.HasPrefix(s.Password, bcryptPwdPrefix) { 254 if err := bcrypt.CompareHashAndPassword([]byte(s.Password), []byte(password)); err != nil { 255 return false, ErrInvalidCredentials 256 } 257 return true, nil 258 } 259 match, err := argon2id.ComparePasswordAndHash(password, s.Password) 260 if !match || err != nil { 261 return false, ErrInvalidCredentials 262 } 263 return match, err 264} 265 266// IsUsable checks if the share is usable from the specified IP 267func (s *Share) IsUsable(ip string) (bool, error) { 268 if s.MaxTokens > 0 && s.UsedTokens >= s.MaxTokens { 269 return false, util.NewRecordNotFoundError("max share usage exceeded") 270 } 271 if s.ExpiresAt > 0 { 272 if s.ExpiresAt < util.GetTimeAsMsSinceEpoch(time.Now()) { 273 return false, util.NewRecordNotFoundError("share expired") 274 } 275 } 276 if len(s.AllowFrom) == 0 { 277 return true, nil 278 } 279 parsedIP := net.ParseIP(ip) 280 if parsedIP == nil { 281 return false, ErrLoginNotAllowedFromIP 282 } 283 for _, ipMask := range s.AllowFrom { 284 _, network, err := net.ParseCIDR(ipMask) 285 if err != nil { 286 continue 287 } 288 if network.Contains(parsedIP) { 289 return true, nil 290 } 291 } 292 return false, ErrLoginNotAllowedFromIP 293} 294