package database import ( "context" "encoding/json" "fmt" "strings" "time" "edge-infra.dev/pkg/edge/iam/config" "edge-infra.dev/pkg/edge/iam/pin" "github.com/pkg/errors" "golang.org/x/crypto/bcrypt" iamErrors "edge-infra.dev/pkg/edge/iam/errors" ) func (s *Store) SavePIN(ctx context.Context, userID string, pincode string) error { key := keyFrom(KeyPrefixPIN, userID) var doc *Doc var err error if doc, err = s.getDoc(ctx, key); err != nil { return err } var pinData pin.Data var previousPins []string // if doc exists we update the previousPin field // else it will be set to NILL as it is a first time pin user. if doc != nil { jsonErr := json.Unmarshal(doc.Value, &pinData) if jsonErr != nil { return errors.WithMessage(jsonErr, "invalid user pin schema detected") } // We append active pin hash as well to check with the new pin entered previousPins = pinData.PreviousPins previousPins = append(previousPins, pinData.Hash) n := len(previousPins) // Iterates over previous pins, so that user cant set the same pin as before for i := n - 1; i >= n-int(config.PINHistoryLength()) && i >= 0; i-- { hash := previousPins[i] if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(pincode)); err == nil { return iamErrors.ErrPINPreviouslyUsed } } } hash, _ := bcrypt.GenerateFromPassword([]byte(pincode), config.BcryptCost()) // removes the first pin when threshold (5) is reached if len(previousPins) > 5 { previousPins = previousPins[1:] } userPIN := &pin.Data{ Subject: userID, Hash: string(hash), LastUpdated: time.Now().Unix(), NumOfWrongAttempts: 0, PreviousPins: previousPins, } payload, err := json.Marshal(userPIN) if err != nil { return errors.WithStack(err) } if err := s.updateDoc(ctx, key, payload, WithExpiration(config.GetPINTTL())); err != nil { return errors.WithStack(err) } return nil } func (s *Store) LoginWithPIN(ctx context.Context, userID string, pincode string) (*pin.Data, error) { key := keyFrom(KeyPrefixPIN, userID) var doc *Doc var err error if doc, err = s.getDoc(ctx, key); err != nil { return nil, err } if doc == nil { //nolint complexity // check if getDoc with FQID exists fqn := ToFullyQualified(userID) if doc, err = s.getDoc(ctx, fqn); err != nil { return nil, err } if doc == nil { return nil, iamErrors.ErrUserNotFound } // If so, migrate to pin:username template and continue if setErr := s.copyDoc(ctx, key, doc.Value, doc.Expiration); setErr != nil { // do not block login if we are not online with the database, and if we fail to copy from FQN to simple username if !s.isOffline { return nil, setErr } } // delete the old doc if err := s.deleteDoc(ctx, fqn); err != nil { if !s.isOffline { return nil, err } } } var userPIN pin.Data res := doc.Value jsonErr := json.Unmarshal(res, &userPIN) if jsonErr != nil { return nil, errors.WithMessage(jsonErr, "invalid user pin schema detected") } // force pin reset if the subject still uses old format e.g. acct:dev-ex@ew250055 if strings.HasPrefix(userPIN.Subject, "acct:") { return nil, iamErrors.ErrPINExpired } // Accessing Expiry time pinDuration := time.Since(time.Unix(userPIN.LastUpdated, 0)) // Check if expiry is before the current time if pinDuration > config.GetPINLifeSpan() { return nil, iamErrors.ErrPINExpired } // User will be displayed the warning of excess pin attempts when wrong pin is entered more than the Threshold no of times if userPIN.NumOfWrongAttempts > config.GetPINRetryThreshold()-1 { return nil, iamErrors.ErrPINThresholdReached } compareErr := bcrypt.CompareHashAndPassword([]byte(userPIN.Hash), []byte(pincode)) // password is correct if compareErr == nil { if s.IsOffline() { // just return the user, we can't reset the wrong login attempts counter return &userPIN, nil } // db is online --> let's reset the wrong login attempts counter userPIN.NumOfWrongAttempts = 0 payload, marshalErr := json.Marshal(&userPIN) if marshalErr != nil { return nil, errors.WithStack(marshalErr) } if setErr := s.updateDoc(ctx, key, payload); setErr != nil { return nil, setErr } return &userPIN, nil } // password did not match, need to update the number of wrong attempts...let's make sure db is online if s.IsOffline() { // skipping the db update... s.Log.Error(iamErrors.ErrIncorrectPIN, "offline detected. skipping the update of number of wrong login attempts ") return nil, iamErrors.ErrIncorrectPIN } // db is online, we can update the number of wrong attempt userPIN.NumOfWrongAttempts++ payload, marshalErr := json.Marshal(&userPIN) if marshalErr != nil { return nil, errors.WithStack(marshalErr) } if setErr := s.updateDoc(ctx, key, payload); setErr != nil { return nil, setErr } // User will be displayed the warning of excess pin attempts when wrong pin is entered more than the Threshold no of times if userPIN.NumOfWrongAttempts >= config.GetPINRetryThreshold() { return nil, iamErrors.ErrPINThresholdReached } return nil, iamErrors.ErrIncorrectPIN } // ToFullyQualified returns a fully qualified name in the form of `acct:{org}@{username}`. func ToFullyQualified(userID string) string { fqn := userID if !strings.Contains(userID, "pin:acct:") { fqn = fmt.Sprintf("pin:acct:%v@%v", config.OrganizationName(), userID) } return fqn }