123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331 |
- // 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 <https://www.gnu.org/licenses/>.
- package dataprovider
- import (
- "encoding/json"
- "fmt"
- "net"
- "strings"
- "time"
- "github.com/alexedwards/argon2id"
- "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) {
- 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 {
- p = strings.TrimSpace(p)
- if 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
- }
|