Documentation
¶
Overview ¶
Package pin provides secure PIN management, including hashing, validation, and lockout mechanisms.
Package pin provides PIN management services including creation, verification, change, reset, and security question handling for USSD-based user authentication.
Index ¶
- Constants
- Variables
- func NormalizeAnswer(answer string) string
- func ValidatePIN(pin string) error
- type QuestionAnswer
- type SecurityQuestionRepository
- func (r *SecurityQuestionRepository) DeleteByUserID(ctx context.Context, userID string) error
- func (r *SecurityQuestionRepository) GetByUserID(ctx context.Context, userID string) ([]models.SecurityQuestion, error)
- func (r *SecurityQuestionRepository) GetQuestionIDsByUserID(ctx context.Context, userID string) ([]int, error)
- func (r *SecurityQuestionRepository) UpsertForUser(ctx context.Context, questions []models.SecurityQuestion) error
- type Service
- func (s *Service) ChangePIN(ctx context.Context, userID, oldPin, newPin string) error
- func (s *Service) GetRemainingAttempts(ctx context.Context, userID string) (int, error)
- func (s *Service) GetUserQuestionIDs(ctx context.Context, userID string) ([]int, error)
- func (s *Service) HasPIN(ctx context.Context, userID string) (bool, error)
- func (s *Service) IsLocked(ctx context.Context, userID string) (bool, time.Time, error)
- func (s *Service) ResetPIN(ctx context.Context, userID, newPin string) error
- func (s *Service) SetPIN(ctx context.Context, userID, pin string) error
- func (s *Service) SetSecurityQuestions(ctx context.Context, userID string, questions []QuestionAnswer) error
- func (s *Service) VerifyPIN(ctx context.Context, userID, pin string) (bool, error)
- func (s *Service) VerifySecurityAnswers(ctx context.Context, userID string, answers []QuestionAnswer) (bool, error)
Constants ¶
const ( // MaxPINAttempts is the number of consecutive wrong PIN entries allowed // before the account is locked. MaxPINAttempts = 3 // LockoutDuration is how long an account stays locked after exceeding // MaxPINAttempts. LockoutDuration = 15 * time.Minute // BcryptCost is the bcrypt work factor used for hashing PINs and // security question answers. BcryptCost = 10 )
Service constants.
const PINLength = 4
PINLength is the required number of digits in a valid PIN.
Variables ¶
var ( // ErrAccountLocked is returned when a PIN operation is attempted on a // locked account. ErrAccountLocked = errors.New("account is temporarily locked") // ErrPINNotSet is returned when a PIN operation requires an existing PIN // but the user has not set one. ErrPINNotSet = errors.New("PIN has not been set") // ErrPINIncorrect is returned when the supplied PIN does not match the // stored hash. ErrPINIncorrect = errors.New("incorrect PIN") // ErrPINSameAsOld is returned when a new PIN is identical to the current // one during a change operation. ErrPINSameAsOld = errors.New("new PIN must be different from current PIN") // ErrSecurityAnswerMismatch is returned when one or more security // question answers do not match. ErrSecurityAnswerMismatch = errors.New("security answers do not match") // ErrInsufficientQuestions is returned when fewer than the required // number of security questions are provided. ErrInsufficientQuestions = errors.New("at least 2 security questions are required") )
Service errors.
var ( // ErrPINLength is returned when the PIN is not exactly PINLength digits. ErrPINLength = fmt.Errorf("PIN must be exactly %d digits", PINLength) // ErrPINNotDigits is returned when the PIN contains non-digit characters. ErrPINNotDigits = errors.New("PIN must contain only digits") // ErrPINAllSame is returned when every digit in the PIN is identical // (e.g. 0000, 1111). ErrPINAllSame = errors.New("PIN must not be all the same digit") // ErrPINSequential is returned when the PIN is a sequential run of // ascending or descending digits (e.g. 1234, 9876). ErrPINSequential = errors.New("PIN must not be a sequential number") )
PIN validation errors.
var ( // ErrSecurityQuestionNotFound is returned when no security questions // exist for the requested user. ErrSecurityQuestionNotFound = errors.New("security questions not found for user") )
Repository errors.
Functions ¶
func NormalizeAnswer ¶
NormalizeAnswer prepares a security question answer for hashing by trimming whitespace and lowercasing. This reduces false negatives from trivial formatting differences (e.g. "Nakuru" vs "nakuru").
func ValidatePIN ¶
ValidatePIN checks that pin satisfies all strength requirements:
- Exactly PINLength digits
- Not all the same digit (e.g. 1111)
- Not a sequential ascending or descending run (e.g. 1234, 4321)
Returns nil if the PIN is acceptable.
Types ¶
type QuestionAnswer ¶
QuestionAnswer pairs a predefined question ID with the user's plaintext answer. The answer is normalized and hashed before storage.
type SecurityQuestionRepository ¶
type SecurityQuestionRepository struct {
// contains filtered or unexported fields
}
SecurityQuestionRepository provides data access for the security_questions table. It is safe for concurrent use because each method scopes its own GORM session via WithContext.
func NewSecurityQuestionRepository ¶
func NewSecurityQuestionRepository(db *gorm.DB) *SecurityQuestionRepository
NewSecurityQuestionRepository creates a new SecurityQuestionRepository backed by the provided GORM connection. The caller must ensure db is non-nil.
func (*SecurityQuestionRepository) DeleteByUserID ¶
func (r *SecurityQuestionRepository) DeleteByUserID(ctx context.Context, userID string) error
DeleteByUserID removes all security questions for the given user. This is typically called when an account is being deactivated.
func (*SecurityQuestionRepository) GetByUserID ¶
func (r *SecurityQuestionRepository) GetByUserID(ctx context.Context, userID string) ([]models.SecurityQuestion, error)
GetByUserID returns all security questions for the given user, ordered by question_id. Returns ErrSecurityQuestionNotFound if no rows exist.
func (*SecurityQuestionRepository) GetQuestionIDsByUserID ¶
func (r *SecurityQuestionRepository) GetQuestionIDsByUserID(ctx context.Context, userID string) ([]int, error)
GetQuestionIDsByUserID returns the question IDs that the user has configured, in ascending order. This is used to prompt the user with their own questions during recovery without exposing answer hashes.
func (*SecurityQuestionRepository) UpsertForUser ¶
func (r *SecurityQuestionRepository) UpsertForUser(ctx context.Context, questions []models.SecurityQuestion) error
UpsertForUser inserts or updates security questions for a user. Each question is upserted on the (user_id, question_id) unique constraint, replacing the answer_hash if the row already exists. The operation runs inside a transaction so that all questions succeed or none do.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service provides PIN management operations including creation, verification, change, reset, and security question handling. It sends SMS notifications as side effects of certain operations via an contracts.AccountNotifier.
All methods that access the database accept a context.Context for cancellation and timeout propagation.
func NewService ¶
func NewService( userRepo repository.UserRepository, sqRepo *SecurityQuestionRepository, notifier contracts.AccountNotifier, lockout time.Duration, ) *Service
NewService creates a new PIN management service. If notifier is nil, a notifications.NoOpAccountNotifier is used. If lockout is 0, the default LockoutDuration is applied.
func (*Service) ChangePIN ¶
ChangePIN verifies the old PIN then sets a new one. The new PIN must differ from the old and pass strength validation. Sends an SMS on success or failure.
func (*Service) GetRemainingAttempts ¶
GetRemainingAttempts returns how many PIN attempts the user has left before lockout. Returns 0 if the account is locked.
func (*Service) GetUserQuestionIDs ¶
GetUserQuestionIDs returns the predefined question IDs that the user has configured for recovery. Returns an empty slice if none are set.
func (*Service) IsLocked ¶
IsLocked reports whether the user's account is currently locked due to failed PIN attempts. If locked, it returns the time the lockout expires.
func (*Service) ResetPIN ¶
ResetPIN sets a new PIN without requiring the old one. This is called after the caller has verified the user's identity via security questions. It clears any lockout state. Sends an SMS on success or failure.
func (*Service) SetPIN ¶
SetPIN creates a PIN for a user who does not yet have one. The PIN is validated for strength, hashed with bcrypt, and stored. Returns a validation error if the PIN is weak.
func (*Service) SetSecurityQuestions ¶
func (s *Service) SetSecurityQuestions(ctx context.Context, userID string, questions []QuestionAnswer) error
SetSecurityQuestions stores hashed answers for the given questions. At least 2 questions are required. Answers are normalized (trimmed, lowercased) before hashing with bcrypt. Existing questions for the user are upserted.
func (*Service) VerifyPIN ¶
VerifyPIN checks the supplied PIN against the stored hash. On failure it increments the attempt counter and sends an SMS notification. If the maximum attempts are exceeded the account is locked and a lockout notification is sent.
Returns (true, nil) on success, (false, nil) on wrong PIN (with side effects), or (false, error) on system errors or if the account is locked.
func (*Service) VerifySecurityAnswers ¶
func (s *Service) VerifySecurityAnswers(ctx context.Context, userID string, answers []QuestionAnswer) (bool, error)
VerifySecurityAnswers checks the supplied answers against the stored hashes for the given user. All answers must match for the result to be true. Answers are normalized before comparison.