Revert "feat(loyalty): EnakPoint & EnakCoin" (#32)
This reverts merge commit645da30, returning main tof0ff59f. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
645da3048e
commit
4e24f9bbb0
@@ -1,471 +0,0 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user