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" ) type pinVerifier interface { VerifyPin(ctx context.Context, customerID uuid.UUID, pin string, action PinAction, info models.CustomerPinRequestInfo) error } // 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) } // NotificationTypePinLocked is the data type of the push a customer gets when their // PIN locks, so the app can offer the PIN reset. const NotificationTypePinLocked = "PIN_LOCKED" // 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 notifier customerNotifier now func() time.Time cost int } func NewCustomerPinProcessor(repo repository.CustomerPinRepository, otp pinOtpSender, notifier customerNotifier) *CustomerPinProcessor { return &CustomerPinProcessor{repo: repo, otp: otp, notifier: notifier, 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(ctx, 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) } } // alertLocked pushes the lock to the customer's app through FCM (F11). It is best // effort: the lock stands whether or not the push goes out. func (p *CustomerPinProcessor) alertLocked(ctx context.Context, state *repository.CustomerPinState, until time.Time) { if p.notifier == nil { return } body := 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) data := map[string]string{ "type": NotificationTypePinLocked, "locked_until": until.UTC().Format(time.RFC3339), } if err := p.notifier.Notify(ctx, state.CustomerID, "PIN terkunci", body, data); 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 }