feat(loyalty): customer PIN
Adds the 6-digit customer PIN that approves every action moving EnakPoint or EnakCoin on the customer's request (docs/prd-point-coin.md K8, F11, Q16, Q17, PC-301). Migration 000093 adds the PIN columns to customers and the customer_security_events table. PIN data is read and written only through CustomerPinRepository, never the Customer entity, so the hash cannot reach a customer response. Only a bcrypt hash is stored. - /customer/pin: status, OTP (pin_setup, pin_reset), create, change, reset. The OTP must be for that purpose and sent to the customer's own number; the existing OTP validation checks neither. A new PIN is checked (6 digits, confirmed, not one digit, not a run up or down, not the birth date as DDMMYY or YYMMDD) before the OTP is spent. - Five wrong attempts in a row lock the PIN for 30 minutes; the counter is incremented in one statement so attempts at the same time all count, and a lock that ran out starts a new series. A locked PIN is refused even when right. The customer is told by WhatsApp, as there is no push channel to customers yet; only the attempt that reached the limit alerts. - A reset through OTP lifts the lock and holds outgoing transfers for 24 hours; paying and exchanging still work, and a held transfer costs no attempt. - VerifyPin(ctx, customer, pin, action) for the flows that follow, with PIN_NOT_SET, PIN_INVALID (attempts left), PIN_LOCKED and TRANSFER_BLOCKED (until when), which PinErrorResponse turns into distinct codes and statuses. - DELETE /marketing/customers/:id/pin (loyalty managers, reason required) and GET /marketing/customers/:id/security-events, scoped to the organization. Every PIN event is in the security log with IP and user agent. No message or binding error contains a PIN. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
fc97c78300
commit
8370851ed2
@@ -0,0 +1,467 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// PIN rules (docs/prd-point-coin.md F11, Q16, Q17).
|
||||
const (
|
||||
pinLength = 6
|
||||
pinMaxAttempts = 5
|
||||
pinLockDuration = 30 * time.Minute
|
||||
pinTransferHold = 24 * time.Hour
|
||||
pinSecurityReasonN = 255
|
||||
|
||||
PinOtpPurposeSetup = "pin_setup"
|
||||
PinOtpPurposeReset = "pin_reset"
|
||||
)
|
||||
|
||||
// Security log events.
|
||||
const (
|
||||
PinEventSet = "PIN_SET"
|
||||
PinEventChanged = "PIN_CHANGED"
|
||||
PinEventReset = "PIN_RESET"
|
||||
PinEventFailed = "PIN_FAILED"
|
||||
PinEventLocked = "PIN_LOCKED"
|
||||
PinEventRemovedByAdmin = "PIN_REMOVED_BY_ADMIN"
|
||||
)
|
||||
|
||||
// What a PIN approves. Only a transfer is held after a reset.
|
||||
type PinAction string
|
||||
|
||||
const (
|
||||
PinActionPay PinAction = "PAY"
|
||||
PinActionExchange PinAction = "EXCHANGE"
|
||||
PinActionTransfer PinAction = "TRANSFER"
|
||||
)
|
||||
|
||||
// Codes of PinError, which the apps tell apart (docs/prd-point-coin.md §9).
|
||||
const (
|
||||
PinErrNotSet = "PIN_NOT_SET"
|
||||
PinErrInvalid = "PIN_INVALID"
|
||||
PinErrLocked = "PIN_LOCKED"
|
||||
PinErrTransferBlocked = "TRANSFER_BLOCKED"
|
||||
)
|
||||
|
||||
// PinError is why a PIN did not approve an action.
|
||||
type PinError struct {
|
||||
Code string
|
||||
// Set for PIN_INVALID: attempts left before the PIN locks.
|
||||
RemainingAttempts int
|
||||
// Set for PIN_LOCKED and TRANSFER_BLOCKED.
|
||||
Until *time.Time
|
||||
}
|
||||
|
||||
func (e *PinError) Error() string {
|
||||
switch e.Code {
|
||||
case PinErrNotSet:
|
||||
return "PIN has not been set"
|
||||
case PinErrInvalid:
|
||||
return fmt.Sprintf("wrong PIN, %d attempts left", e.RemainingAttempts)
|
||||
case PinErrLocked:
|
||||
return fmt.Sprintf("PIN is locked until %s", e.Until.Format(time.RFC3339))
|
||||
case PinErrTransferBlocked:
|
||||
return fmt.Sprintf("transfers are on hold after a PIN reset until %s", e.Until.Format(time.RFC3339))
|
||||
}
|
||||
return e.Code
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrInvalidPinInput wraps a PIN that is malformed, weak, or not confirmed. The
|
||||
// message never contains the PIN.
|
||||
ErrInvalidPinInput = errors.New("invalid PIN")
|
||||
// ErrPinAlreadySet means a first PIN was requested for a customer who has one.
|
||||
ErrPinAlreadySet = errors.New("PIN has already been set")
|
||||
// ErrPinOtpInvalid means the OTP was wrong, expired, used, for another purpose, or
|
||||
// sent to another number.
|
||||
ErrPinOtpInvalid = errors.New("invalid or expired OTP")
|
||||
// ErrPinOtpTooSoon means an OTP was requested again too quickly.
|
||||
ErrPinOtpTooSoon = errors.New("an OTP was sent recently; wait before asking again")
|
||||
// ErrPinNoPhone means the customer has no phone number to send an OTP to.
|
||||
ErrPinNoPhone = errors.New("customer has no phone number")
|
||||
)
|
||||
|
||||
type pinOtpSender interface {
|
||||
CanResendOtp(ctx context.Context, phoneNumber string, purpose string) (bool, int, error)
|
||||
CreateOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error)
|
||||
SendOtpViaWhatsApp(phoneNumber string, otpCode string, purpose string) error
|
||||
ValidateOtpSession(ctx context.Context, token string, code string) (*entities.OtpSession, error)
|
||||
}
|
||||
|
||||
// pinAlerter tells a customer their PIN was locked. There is no push channel to
|
||||
// customers yet, so the app sends it by WhatsApp.
|
||||
type pinAlerter interface {
|
||||
SendWhatsAppMessage(phoneNumber, message string) error
|
||||
}
|
||||
|
||||
// CustomerPinProcessor manages customer PINs (docs/prd-point-coin.md F11). Every flow
|
||||
// that moves balance on the customer's request calls VerifyPin first (K8).
|
||||
type CustomerPinProcessor struct {
|
||||
repo repository.CustomerPinRepository
|
||||
otp pinOtpSender
|
||||
alerter pinAlerter
|
||||
now func() time.Time
|
||||
cost int
|
||||
}
|
||||
|
||||
func NewCustomerPinProcessor(repo repository.CustomerPinRepository, otp pinOtpSender, alerter pinAlerter) *CustomerPinProcessor {
|
||||
return &CustomerPinProcessor{repo: repo, otp: otp, alerter: alerter, now: time.Now, cost: bcrypt.DefaultCost}
|
||||
}
|
||||
|
||||
func (p *CustomerPinProcessor) Status(ctx context.Context, customerID uuid.UUID) (*models.CustomerPinStatus, error) {
|
||||
state, err := p.repo.GetState(ctx, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := p.now()
|
||||
status := &models.CustomerPinStatus{HasPin: state.PinHash != nil}
|
||||
if state.LockedUntil != nil && state.LockedUntil.After(now) {
|
||||
status.LockedUntil = state.LockedUntil
|
||||
}
|
||||
if state.TransferBlockedUntil != nil && state.TransferBlockedUntil.After(now) {
|
||||
status.TransferBlockedUntil = state.TransferBlockedUntil
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// RequestOtp sends an OTP to the customer's own phone number, for creating a first PIN
|
||||
// (pin_setup) or resetting a forgotten one (pin_reset).
|
||||
func (p *CustomerPinProcessor) RequestOtp(ctx context.Context, customerID uuid.UUID, purpose string) (*models.CustomerPinOtp, error) {
|
||||
state, err := p.repo.GetState(ctx, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch purpose {
|
||||
case PinOtpPurposeSetup:
|
||||
if state.PinHash != nil {
|
||||
return nil, ErrPinAlreadySet
|
||||
}
|
||||
case PinOtpPurposeReset:
|
||||
if state.PinHash == nil {
|
||||
return nil, &PinError{Code: PinErrNotSet}
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: purpose must be %s or %s", ErrInvalidPinInput, PinOtpPurposeSetup, PinOtpPurposeReset)
|
||||
}
|
||||
if state.PhoneNumber == nil || *state.PhoneNumber == "" {
|
||||
return nil, ErrPinNoPhone
|
||||
}
|
||||
|
||||
canSend, _, err := p.otp.CanResendOtp(ctx, *state.PhoneNumber, purpose)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !canSend {
|
||||
return nil, ErrPinOtpTooSoon
|
||||
}
|
||||
session, err := p.otp.CreateOtpSession(ctx, *state.PhoneNumber, purpose)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := p.otp.SendOtpViaWhatsApp(*state.PhoneNumber, session.Code, purpose); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &models.CustomerPinOtp{Purpose: purpose, OtpToken: session.Token, ExpiresAt: session.ExpiresAt}, nil
|
||||
}
|
||||
|
||||
// CreatePin sets a customer's first PIN, approved by an OTP to their phone so it is set
|
||||
// by the owner of the number and not by whoever holds a logged-in phone.
|
||||
func (p *CustomerPinProcessor) CreatePin(ctx context.Context, customerID uuid.UUID, otpToken, otpCode, pin, confirmPin string, info models.CustomerPinRequestInfo) error {
|
||||
state, err := p.repo.GetState(ctx, customerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state.PinHash != nil {
|
||||
return ErrPinAlreadySet
|
||||
}
|
||||
// Check the PIN before spending the OTP, so a weak PIN does not cost a new code.
|
||||
if err := checkNewPin(pin, confirmPin, state.BirthDate); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.checkOtp(ctx, state, otpToken, otpCode, PinOtpPurposeSetup); err != nil {
|
||||
return err
|
||||
}
|
||||
hash, err := p.hash(pin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.repo.SetPin(ctx, customerID, hash, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
p.logEvent(ctx, customerID, PinEventSet, nil, nil, info)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChangePin replaces the PIN after checking the old one, which counts toward the lock
|
||||
// like any other attempt. A transfer hold from an earlier reset stays.
|
||||
func (p *CustomerPinProcessor) ChangePin(ctx context.Context, customerID uuid.UUID, oldPin, pin, confirmPin string, info models.CustomerPinRequestInfo) error {
|
||||
state, err := p.repo.GetState(ctx, customerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkNewPin(pin, confirmPin, state.BirthDate); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.verify(ctx, state, oldPin, PinActionPay, info); err != nil {
|
||||
return err
|
||||
}
|
||||
hash, err := p.hash(pin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.repo.SetPin(ctx, customerID, hash, p.activeHold(state)); err != nil {
|
||||
return err
|
||||
}
|
||||
p.logEvent(ctx, customerID, PinEventChanged, nil, nil, info)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetPin sets a new PIN for a customer who forgot theirs, approved by an OTP. It also
|
||||
// lifts a lock, and holds outgoing transfers for 24 hours in case the phone number was
|
||||
// taken over (Q16).
|
||||
func (p *CustomerPinProcessor) ResetPin(ctx context.Context, customerID uuid.UUID, otpToken, otpCode, pin, confirmPin string, info models.CustomerPinRequestInfo) error {
|
||||
state, err := p.repo.GetState(ctx, customerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state.PinHash == nil {
|
||||
return &PinError{Code: PinErrNotSet}
|
||||
}
|
||||
if err := checkNewPin(pin, confirmPin, state.BirthDate); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.checkOtp(ctx, state, otpToken, otpCode, PinOtpPurposeReset); err != nil {
|
||||
return err
|
||||
}
|
||||
hash, err := p.hash(pin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hold := p.now().Add(pinTransferHold)
|
||||
if err := p.repo.SetPin(ctx, customerID, hash, &hold); err != nil {
|
||||
return err
|
||||
}
|
||||
p.logEvent(ctx, customerID, PinEventReset, nil, nil, info)
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyPin checks the PIN before an action that moves balance. It returns a *PinError
|
||||
// with the code the apps act on: PIN_NOT_SET, PIN_INVALID (with the attempts left),
|
||||
// PIN_LOCKED or TRANSFER_BLOCKED (with until when).
|
||||
func (p *CustomerPinProcessor) VerifyPin(ctx context.Context, customerID uuid.UUID, pin string, action PinAction, info models.CustomerPinRequestInfo) error {
|
||||
state, err := p.repo.GetState(ctx, customerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.verify(ctx, state, pin, action, info)
|
||||
}
|
||||
|
||||
func (p *CustomerPinProcessor) verify(ctx context.Context, state *repository.CustomerPinState, pin string, action PinAction, info models.CustomerPinRequestInfo) error {
|
||||
if state.PinHash == nil {
|
||||
return &PinError{Code: PinErrNotSet}
|
||||
}
|
||||
now := p.now()
|
||||
// A locked PIN is refused before it is compared, even when it is right.
|
||||
if state.LockedUntil != nil && state.LockedUntil.After(now) {
|
||||
until := *state.LockedUntil
|
||||
return &PinError{Code: PinErrLocked, Until: &until}
|
||||
}
|
||||
// A held transfer is refused before the PIN is compared, so it costs no attempt.
|
||||
if action == PinActionTransfer && state.TransferBlockedUntil != nil && state.TransferBlockedUntil.After(now) {
|
||||
until := *state.TransferBlockedUntil
|
||||
return &PinError{Code: PinErrTransferBlocked, Until: &until}
|
||||
}
|
||||
|
||||
if bcrypt.CompareHashAndPassword([]byte(*state.PinHash), []byte(pin)) != nil {
|
||||
attempts, lockedUntil, err := p.repo.RecordFailure(ctx, state.CustomerID, pinMaxAttempts, now, now.Add(pinLockDuration))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.logEvent(ctx, state.CustomerID, PinEventFailed, nil, nil, info)
|
||||
if lockedUntil != nil && lockedUntil.After(now) {
|
||||
// Only the attempt that reached the limit logs the lock and tells the
|
||||
// customer; attempts racing it just see the lock.
|
||||
if attempts == pinMaxAttempts {
|
||||
p.logEvent(ctx, state.CustomerID, PinEventLocked, nil, nil, info)
|
||||
p.alertLocked(state, *lockedUntil)
|
||||
}
|
||||
return &PinError{Code: PinErrLocked, Until: lockedUntil}
|
||||
}
|
||||
return &PinError{Code: PinErrInvalid, RemainingAttempts: pinMaxAttempts - attempts}
|
||||
}
|
||||
if state.FailedAttempts > 0 || state.LockedUntil != nil {
|
||||
if err := p.repo.ClearFailures(ctx, state.CustomerID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemovePinByAdmin deletes a customer's PIN, for example when they lost access to it,
|
||||
// so they have to create a new one through OTP. Admins can never set or read a PIN.
|
||||
func (p *CustomerPinProcessor) RemovePinByAdmin(ctx context.Context, organizationID, customerID, adminID uuid.UUID, reason string, info models.CustomerPinRequestInfo) error {
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
return fmt.Errorf("%w: a reason is required", ErrInvalidPinInput)
|
||||
}
|
||||
if adminID == uuid.Nil {
|
||||
return fmt.Errorf("%w: the admin is unknown", ErrInvalidPinInput)
|
||||
}
|
||||
state, err := p.repo.GetState(ctx, customerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state.OrganizationID != organizationID {
|
||||
return repository.ErrPinCustomerNotFound
|
||||
}
|
||||
if state.PinHash == nil {
|
||||
return &PinError{Code: PinErrNotSet}
|
||||
}
|
||||
if err := p.repo.RemovePin(ctx, customerID); err != nil {
|
||||
return err
|
||||
}
|
||||
reason = truncateRunes(reason, pinSecurityReasonN)
|
||||
p.logEvent(ctx, customerID, PinEventRemovedByAdmin, &adminID, &reason, info)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListEvents returns a page of a customer's PIN security log for the dashboard.
|
||||
func (p *CustomerPinProcessor) ListEvents(ctx context.Context, organizationID, customerID uuid.UUID, page, limit int) (*models.PaginatedResponse[models.CustomerSecurityEventView], error) {
|
||||
state, err := p.repo.GetState(ctx, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if state.OrganizationID != organizationID {
|
||||
return nil, repository.ErrPinCustomerNotFound
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
rows, total, err := p.repo.ListEvents(ctx, customerID, (page-1)*limit, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events := make([]models.CustomerSecurityEventView, 0, len(rows))
|
||||
for _, e := range rows {
|
||||
events = append(events, models.CustomerSecurityEventView{
|
||||
ID: e.ID, Event: e.Event, ActorUser: e.ActorUser, Reason: e.Reason,
|
||||
IPAddress: e.IPAddress, UserAgent: e.UserAgent, CreatedAt: e.CreatedAt,
|
||||
})
|
||||
}
|
||||
return &models.PaginatedResponse[models.CustomerSecurityEventView]{
|
||||
Data: events,
|
||||
Pagination: models.Pagination{
|
||||
Page: page, Limit: limit, Total: total, TotalPages: int((total + int64(limit) - 1) / int64(limit)),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// checkOtp validates an OTP and that it was issued for this purpose to this customer's
|
||||
// own phone number. Without those checks an OTP from the login flow, or one sent to
|
||||
// another number, could approve a PIN change.
|
||||
func (p *CustomerPinProcessor) checkOtp(ctx context.Context, state *repository.CustomerPinState, token, code, purpose string) error {
|
||||
if token == "" || code == "" || state.PhoneNumber == nil {
|
||||
return ErrPinOtpInvalid
|
||||
}
|
||||
session, err := p.otp.ValidateOtpSession(ctx, token, code)
|
||||
if err != nil || session == nil {
|
||||
return ErrPinOtpInvalid
|
||||
}
|
||||
if session.Purpose != purpose || session.PhoneNumber != *state.PhoneNumber {
|
||||
return ErrPinOtpInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *CustomerPinProcessor) hash(pin string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(pin), p.cost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to hash PIN: %w", err)
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
func (p *CustomerPinProcessor) activeHold(state *repository.CustomerPinState) *time.Time {
|
||||
if state.TransferBlockedUntil != nil && state.TransferBlockedUntil.After(p.now()) {
|
||||
return state.TransferBlockedUntil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// logEvent records a security event. The log is best effort: failing to write it must
|
||||
// not undo what the customer just did, so a failure is logged instead.
|
||||
func (p *CustomerPinProcessor) logEvent(ctx context.Context, customerID uuid.UUID, event string, actor *uuid.UUID, reason *string, info models.CustomerPinRequestInfo) {
|
||||
e := repository.CustomerSecurityEvent{CustomerID: customerID, Event: event, ActorUser: actor, Reason: reason}
|
||||
if info.IPAddress != "" {
|
||||
ip := truncateRunes(info.IPAddress, 45)
|
||||
e.IPAddress = &ip
|
||||
}
|
||||
if info.UserAgent != "" {
|
||||
ua := truncateRunes(info.UserAgent, 255)
|
||||
e.UserAgent = &ua
|
||||
}
|
||||
if err := p.repo.InsertEvent(ctx, e); err != nil {
|
||||
logger.NonContext.Error(fmt.Sprintf("Could not record %s for customer %s", event, customerID), err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *CustomerPinProcessor) alertLocked(state *repository.CustomerPinState, until time.Time) {
|
||||
if p.alerter == nil || state.PhoneNumber == nil {
|
||||
return
|
||||
}
|
||||
message := fmt.Sprintf("PIN EnakPoint kamu terkunci sampai %s karena salah dimasukkan %d kali. Jika ini bukan kamu, segera reset PIN lewat aplikasi.",
|
||||
until.In(walletDisplayLocation).Format("02 Jan 2006 15:04 WIB"), pinMaxAttempts)
|
||||
if err := p.alerter.SendWhatsAppMessage(*state.PhoneNumber, message); err != nil {
|
||||
logger.NonContext.Error(fmt.Sprintf("Could not tell customer %s their PIN is locked", state.CustomerID), err)
|
||||
}
|
||||
}
|
||||
|
||||
// checkNewPin rejects a PIN that is not 6 digits, does not match its confirmation, or
|
||||
// is easy to guess: one digit repeated, a run up or down, or the birth date as DDMMYY
|
||||
// or YYMMDD.
|
||||
func checkNewPin(pin, confirm string, birthDate *time.Time) error {
|
||||
if len(pin) != pinLength {
|
||||
return fmt.Errorf("%w: a PIN is %d digits", ErrInvalidPinInput, pinLength)
|
||||
}
|
||||
for _, r := range pin {
|
||||
if r < '0' || r > '9' {
|
||||
return fmt.Errorf("%w: a PIN is digits only", ErrInvalidPinInput)
|
||||
}
|
||||
}
|
||||
if pin != confirm {
|
||||
return fmt.Errorf("%w: the PIN and its confirmation differ", ErrInvalidPinInput)
|
||||
}
|
||||
same, up, down := true, true, true
|
||||
for i := 1; i < len(pin); i++ {
|
||||
d := int(pin[i]) - int(pin[i-1])
|
||||
same = same && d == 0
|
||||
up = up && d == 1
|
||||
down = down && d == -1
|
||||
}
|
||||
if same || up || down {
|
||||
return fmt.Errorf("%w: the PIN is too easy to guess", ErrInvalidPinInput)
|
||||
}
|
||||
if birthDate != nil {
|
||||
for _, layout := range []string{"020106", "060102"} {
|
||||
if pin == birthDate.Format(layout) {
|
||||
return fmt.Errorf("%w: the PIN must not be your birth date", ErrInvalidPinInput)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// otpFake keeps OTP sessions in memory with the checks the real one makes.
|
||||
type otpFake struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]*entities.OtpSession
|
||||
sent []string
|
||||
}
|
||||
|
||||
func (f *otpFake) CanResendOtp(context.Context, string, string) (bool, int, error) {
|
||||
return true, 0, nil
|
||||
}
|
||||
|
||||
func (f *otpFake) CreateOtpSession(_ context.Context, phone, purpose string) (*entities.OtpSession, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
s := &entities.OtpSession{Token: uuid.NewString(), Code: "246810", PhoneNumber: phone, Purpose: purpose, ExpiresAt: time.Now().Add(5 * time.Minute)}
|
||||
f.sessions[s.Token] = s
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (f *otpFake) SendOtpViaWhatsApp(phone, code, purpose string) error {
|
||||
f.sent = append(f.sent, purpose)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *otpFake) ValidateOtpSession(_ context.Context, token, code string) (*entities.OtpSession, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
s := f.sessions[token]
|
||||
if s == nil || s.IsUsed || s.Code != code {
|
||||
return nil, errors.New("invalid OTP")
|
||||
}
|
||||
s.IsUsed = true
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// issue creates a session as if it had been sent, for any purpose and number.
|
||||
func (f *otpFake) issue(phone, purpose string) *entities.OtpSession {
|
||||
s, _ := f.CreateOtpSession(context.Background(), phone, purpose)
|
||||
return s
|
||||
}
|
||||
|
||||
type alerterFake struct {
|
||||
mu sync.Mutex
|
||||
messages []string
|
||||
}
|
||||
|
||||
func (f *alerterFake) SendWhatsAppMessage(_ string, message string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.messages = append(f.messages, message)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Needs TEST_DATABASE_URL pointing at a migrated database; see
|
||||
// internal/repository/wallet_repository_test.go.
|
||||
func TestCustomerPin_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
ctx := context.Background()
|
||||
|
||||
org, otherOrg, customer, admin := uuid.New(), uuid.New(), uuid.New(), uuid.New()
|
||||
phone := "0812" + customer.String()[:8]
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(q, args...).Error)
|
||||
}
|
||||
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'pin test', 'basic'), (?, 'other', 'basic')`, org, otherOrg)
|
||||
exec(`INSERT INTO customers (id, organization_id, name, phone_number, birth_date) VALUES (?, ?, 'Budi', ?, '1990-03-14')`, customer, org, phone)
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM customer_security_events WHERE customer_id = ?`, customer)
|
||||
db.Exec(`DELETE FROM customers WHERE id = ?`, customer)
|
||||
db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{org, otherOrg})
|
||||
})
|
||||
|
||||
otp := &otpFake{sessions: map[string]*entities.OtpSession{}}
|
||||
alerts := &alerterFake{}
|
||||
p := NewCustomerPinProcessor(repository.NewCustomerPinRepository(db), otp, alerts)
|
||||
p.cost = bcrypt.MinCost
|
||||
clock := time.Now()
|
||||
var clockMu sync.Mutex
|
||||
p.now = func() time.Time { clockMu.Lock(); defer clockMu.Unlock(); return clock }
|
||||
advance := func(d time.Duration) { clockMu.Lock(); clock = clock.Add(d); clockMu.Unlock() }
|
||||
info := models.CustomerPinRequestInfo{IPAddress: "10.0.0.7", UserAgent: "EnakApp/2.0"}
|
||||
const pin, newPin, resetPin = "482913", "572039", "613408"
|
||||
|
||||
pinErr := func(err error) *PinError {
|
||||
t.Helper()
|
||||
var pe *PinError
|
||||
require.True(t, errors.As(err, &pe), "want a PinError, got %v", err)
|
||||
for _, secret := range []string{pin, newPin, resetPin} {
|
||||
assert.NotContains(t, err.Error(), secret, "an error must never contain a PIN")
|
||||
}
|
||||
return pe
|
||||
}
|
||||
events := func() []string {
|
||||
t.Helper()
|
||||
var out []string
|
||||
require.NoError(t, db.Raw(`SELECT event FROM customer_security_events WHERE customer_id = ? ORDER BY created_at, id`, customer).Scan(&out).Error)
|
||||
return out
|
||||
}
|
||||
|
||||
// No PIN yet: nothing can be approved.
|
||||
status, err := p.Status(ctx, customer)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, status.HasPin)
|
||||
assert.Equal(t, PinErrNotSet, pinErr(p.VerifyPin(ctx, customer, pin, PinActionPay, info)).Code)
|
||||
|
||||
// Creating the first PIN takes an OTP sent to the customer's own number, for this
|
||||
// purpose.
|
||||
sent, err := p.RequestOtp(ctx, customer, PinOtpPurposeSetup)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{PinOtpPurposeSetup}, otp.sent)
|
||||
|
||||
loginOtp := otp.issue(phone, "login")
|
||||
assert.ErrorIs(t, p.CreatePin(ctx, customer, loginOtp.Token, loginOtp.Code, pin, pin, info), ErrPinOtpInvalid, "an OTP for another purpose")
|
||||
strangerOtp := otp.issue("0899999999", PinOtpPurposeSetup)
|
||||
assert.ErrorIs(t, p.CreatePin(ctx, customer, strangerOtp.Token, strangerOtp.Code, pin, pin, info), ErrPinOtpInvalid, "an OTP sent to another number")
|
||||
assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "000000", pin, pin, info), ErrPinOtpInvalid, "a wrong code")
|
||||
|
||||
// A weak PIN is refused before the OTP is used, so the same OTP still works after.
|
||||
assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", "123456", "123456", info), ErrInvalidPinInput)
|
||||
assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", "140390", "140390", info), ErrInvalidPinInput, "birth date")
|
||||
require.NoError(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", pin, pin, info))
|
||||
assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", pin, pin, info), ErrPinAlreadySet)
|
||||
|
||||
var stored string
|
||||
require.NoError(t, db.Raw(`SELECT pin_hash FROM customers WHERE id = ?`, customer).Scan(&stored).Error)
|
||||
assert.NotContains(t, stored, pin, "only a hash is stored")
|
||||
assert.True(t, strings.HasPrefix(stored, "$2"), "bcrypt")
|
||||
|
||||
require.NoError(t, p.VerifyPin(ctx, customer, pin, PinActionPay, info))
|
||||
|
||||
// Four wrong attempts count down; the fifth locks for 30 minutes.
|
||||
for left := 4; left >= 1; left-- {
|
||||
pe := pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info))
|
||||
assert.Equal(t, PinErrInvalid, pe.Code)
|
||||
assert.Equal(t, left, pe.RemainingAttempts)
|
||||
}
|
||||
pe := pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info))
|
||||
assert.Equal(t, PinErrLocked, pe.Code)
|
||||
assert.WithinDuration(t, clock.Add(30*time.Minute), *pe.Until, time.Second)
|
||||
assert.Len(t, alerts.messages, 1, "the customer is told the PIN locked")
|
||||
|
||||
// While locked even the right PIN is refused.
|
||||
pe = pinErr(p.VerifyPin(ctx, customer, pin, PinActionPay, info))
|
||||
assert.Equal(t, PinErrLocked, pe.Code)
|
||||
status, err = p.Status(ctx, customer)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, status.LockedUntil)
|
||||
|
||||
// Once the lock runs out a wrong PIN starts a new series of five.
|
||||
advance(31 * time.Minute)
|
||||
pe = pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info))
|
||||
assert.Equal(t, PinErrInvalid, pe.Code)
|
||||
assert.Equal(t, 4, pe.RemainingAttempts)
|
||||
// The right PIN resets the count.
|
||||
require.NoError(t, p.VerifyPin(ctx, customer, pin, PinActionPay, info))
|
||||
pe = pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info))
|
||||
assert.Equal(t, 4, pe.RemainingAttempts)
|
||||
require.NoError(t, p.VerifyPin(ctx, customer, pin, PinActionPay, info))
|
||||
|
||||
// Wrong attempts made at once all count: none slips past the lock.
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); _ = p.VerifyPin(ctx, customer, "000001", PinActionPay, info) }()
|
||||
}
|
||||
wg.Wait()
|
||||
pe = pinErr(p.VerifyPin(ctx, customer, pin, PinActionPay, info))
|
||||
assert.Equal(t, PinErrLocked, pe.Code)
|
||||
|
||||
// Resetting through OTP lifts the lock and holds transfers for 24 hours.
|
||||
_, err = p.RequestOtp(ctx, customer, PinOtpPurposeReset)
|
||||
require.NoError(t, err)
|
||||
setupOtp := otp.issue(phone, PinOtpPurposeSetup)
|
||||
assert.ErrorIs(t, p.ResetPin(ctx, customer, setupOtp.Token, setupOtp.Code, resetPin, resetPin, info), ErrPinOtpInvalid, "a setup OTP cannot reset")
|
||||
resetOtp := otp.issue(phone, PinOtpPurposeReset)
|
||||
require.NoError(t, p.ResetPin(ctx, customer, resetOtp.Token, resetOtp.Code, resetPin, resetPin, info))
|
||||
status, err = p.Status(ctx, customer)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, status.LockedUntil, "the lock is lifted")
|
||||
require.NotNil(t, status.TransferBlockedUntil)
|
||||
assert.WithinDuration(t, clock.Add(24*time.Hour), *status.TransferBlockedUntil, time.Second)
|
||||
|
||||
require.NoError(t, p.VerifyPin(ctx, customer, resetPin, PinActionPay, info), "paying still works")
|
||||
require.NoError(t, p.VerifyPin(ctx, customer, resetPin, PinActionExchange, info), "exchanging still works")
|
||||
pe = pinErr(p.VerifyPin(ctx, customer, resetPin, PinActionTransfer, info))
|
||||
assert.Equal(t, PinErrTransferBlocked, pe.Code)
|
||||
var failed int
|
||||
require.NoError(t, db.Raw(`SELECT pin_failed_attempts FROM customers WHERE id = ?`, customer).Scan(&failed).Error)
|
||||
assert.Zero(t, failed, "a held transfer costs no attempt")
|
||||
|
||||
// Changing the PIN needs the old one and keeps the transfer hold.
|
||||
assert.Equal(t, PinErrInvalid, pinErr(p.ChangePin(ctx, customer, "000001", newPin, newPin, info)).Code)
|
||||
require.NoError(t, p.ChangePin(ctx, customer, resetPin, newPin, newPin, info))
|
||||
require.NoError(t, p.VerifyPin(ctx, customer, newPin, PinActionPay, info))
|
||||
assert.Equal(t, PinErrTransferBlocked, pinErr(p.VerifyPin(ctx, customer, newPin, PinActionTransfer, info)).Code)
|
||||
advance(25 * time.Hour)
|
||||
require.NoError(t, p.VerifyPin(ctx, customer, newPin, PinActionTransfer, info), "the hold ends after 24 hours")
|
||||
|
||||
// An admin can remove the PIN, only in their own organization and with a reason.
|
||||
assert.ErrorIs(t, p.RemovePinByAdmin(ctx, otherOrg, customer, admin, "hilang HP", info), repository.ErrPinCustomerNotFound)
|
||||
assert.ErrorIs(t, p.RemovePinByAdmin(ctx, org, customer, admin, " ", info), ErrInvalidPinInput)
|
||||
require.NoError(t, p.RemovePinByAdmin(ctx, org, customer, admin, "hilang HP", info))
|
||||
status, err = p.Status(ctx, customer)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, status.HasPin)
|
||||
assert.Equal(t, PinErrNotSet, pinErr(p.VerifyPin(ctx, customer, newPin, PinActionPay, info)).Code)
|
||||
|
||||
// Every event is in the security log, with where it came from.
|
||||
got := events()
|
||||
for _, want := range []string{PinEventSet, PinEventFailed, PinEventLocked, PinEventReset, PinEventChanged, PinEventRemovedByAdmin} {
|
||||
assert.Contains(t, got, want)
|
||||
}
|
||||
page, err := p.ListEvents(ctx, org, customer, 1, 100)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, len(got), page.Pagination.Total)
|
||||
removed := page.Data[0]
|
||||
assert.Equal(t, PinEventRemovedByAdmin, removed.Event)
|
||||
assert.Equal(t, &admin, removed.ActorUser)
|
||||
assert.Equal(t, "hilang HP", *removed.Reason)
|
||||
assert.Equal(t, "10.0.0.7", *removed.IPAddress)
|
||||
_, err = p.ListEvents(ctx, otherOrg, customer, 1, 10)
|
||||
assert.ErrorIs(t, err, repository.ErrPinCustomerNotFound)
|
||||
|
||||
var locked int
|
||||
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM customer_security_events WHERE customer_id = ? AND event = ?`, customer, PinEventLocked).Scan(&locked).Error)
|
||||
assert.Equal(t, locked, len(alerts.messages), "one alert per lock")
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCheckNewPin(t *testing.T) {
|
||||
birth := time.Date(1990, 3, 14, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
for _, ok := range []string{"482913", "019283", "135790", "112233"} {
|
||||
assert.NoError(t, checkNewPin(ok, ok, &birth), ok)
|
||||
}
|
||||
for name, c := range map[string][2]string{
|
||||
"too short": {"12345", "12345"},
|
||||
"too long": {"1234567", "1234567"},
|
||||
"not digits": {"12a456", "12a456"},
|
||||
"confirmation": {"482913", "482914"},
|
||||
"one digit": {"111111", "111111"},
|
||||
"zeros": {"000000", "000000"},
|
||||
"run up": {"123456", "123456"},
|
||||
"run up from 4": {"456789", "456789"},
|
||||
"run down": {"654321", "654321"},
|
||||
"run down from 9": {"987654", "987654"},
|
||||
"birth date DDMMYY": {"140390", "140390"},
|
||||
"birth date YYMMDD": {"900314", "900314"},
|
||||
} {
|
||||
err := checkNewPin(c[0], c[1], &birth)
|
||||
assert.ErrorIs(t, err, ErrInvalidPinInput, name)
|
||||
assert.NotContains(t, err.Error(), c[0], "%s: the message must not echo the PIN", name)
|
||||
}
|
||||
// Without a birth date only the other rules apply.
|
||||
assert.NoError(t, checkNewPin("140390", "140390", nil))
|
||||
}
|
||||
@@ -18,6 +18,8 @@ type OtpProcessor interface {
|
||||
CreateOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error)
|
||||
ResendOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error)
|
||||
SendOtpViaWhatsApp(phoneNumber string, otpCode string, purpose string) error
|
||||
// SendWhatsAppMessage sends any message to a customer number, formatted like OTPs.
|
||||
SendWhatsAppMessage(phoneNumber string, message string) error
|
||||
ValidateOtpCode(code string) bool
|
||||
ValidateOtpSession(ctx context.Context, token string, code string) (*entities.OtpSession, error)
|
||||
InvalidateOtpSession(ctx context.Context, token string) error
|
||||
@@ -133,6 +135,10 @@ func (p *otpProcessor) SendOtpViaWhatsApp(phoneNumber string, otpCode string, pu
|
||||
switch purpose {
|
||||
case "login":
|
||||
message = fmt.Sprintf("Kode OTP untuk login kamu adalah %s. Berlaku 5 menit.", otpCode)
|
||||
case "pin_setup":
|
||||
message = fmt.Sprintf("Kode OTP untuk membuat PIN EnakPoint kamu adalah %s. Berlaku 5 menit. Jangan berikan kode ini kepada siapa pun, termasuk kasir.", otpCode)
|
||||
case "pin_reset":
|
||||
message = fmt.Sprintf("Kode OTP untuk reset PIN EnakPoint kamu adalah %s. Berlaku 5 menit. Jangan berikan kode ini kepada siapa pun. Setelah reset, transfer ditahan 24 jam.", otpCode)
|
||||
case "registration":
|
||||
message = fmt.Sprintf("Kode OTP untuk registrasi kamu adalah %s. Berlaku 5 menit.", otpCode)
|
||||
default:
|
||||
@@ -236,3 +242,10 @@ func (p *otpProcessor) formatPhoneNumber(phoneNumber string) string {
|
||||
|
||||
return digits
|
||||
}
|
||||
|
||||
func (p *otpProcessor) SendWhatsAppMessage(phoneNumber string, message string) error {
|
||||
if err := p.fonnteClient.SendWhatsAppMessage(p.formatPhoneNumber(phoneNumber), message); err != nil {
|
||||
return fmt.Errorf("failed to send WhatsApp message: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user