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
@@ -156,6 +156,7 @@ func (a *App) Initialize(cfg *config.Config) error {
|
|||||||
services.walletAdminService,
|
services.walletAdminService,
|
||||||
validators.walletValidator,
|
validators.walletValidator,
|
||||||
services.loyaltySettingsService,
|
services.loyaltySettingsService,
|
||||||
|
services.customerPinService,
|
||||||
a.redisClient,
|
a.redisClient,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -386,12 +387,15 @@ type processors struct {
|
|||||||
walletAdminProcessor *processor.WalletAdminProcessor
|
walletAdminProcessor *processor.WalletAdminProcessor
|
||||||
loyaltySettingsProcessor *processor.LoyaltySettingsProcessor
|
loyaltySettingsProcessor *processor.LoyaltySettingsProcessor
|
||||||
earningProcessor *processor.EarningProcessor
|
earningProcessor *processor.EarningProcessor
|
||||||
|
customerPinProcessor *processor.CustomerPinProcessor
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
|
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
|
||||||
fileClient := client.NewFileClient(cfg.S3Config)
|
fileClient := client.NewFileClient(cfg.S3Config)
|
||||||
fonnteClient := client.NewFonnteClient(cfg.GetFonnte())
|
fonnteClient := client.NewFonnteClient(cfg.GetFonnte())
|
||||||
otpProcessor := processor.NewOtpProcessor(fonnteClient, repos.otpRepo)
|
otpProcessor := processor.NewOtpProcessor(fonnteClient, repos.otpRepo)
|
||||||
|
// Customer PIN (docs/prd-point-coin.md F11)
|
||||||
|
customerPinProcessor := processor.NewCustomerPinProcessor(repository.NewCustomerPinRepository(a.db), otpProcessor, otpProcessor)
|
||||||
inventoryMovementService := service.NewInventoryMovementService(repos.inventoryMovementRepo, repos.ingredientRepo)
|
inventoryMovementService := service.NewInventoryMovementService(repos.inventoryMovementRepo, repos.ingredientRepo)
|
||||||
|
|
||||||
orderProcessor := processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo, repos.txManager, repos.productRecipeRepo, repos.ingredientRepo, inventoryMovementService, repos.productOutletPriceRepo)
|
orderProcessor := processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo, repos.txManager, repos.productRecipeRepo, repos.ingredientRepo, inventoryMovementService, repos.productOutletPriceRepo)
|
||||||
@@ -448,6 +452,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
|||||||
walletProcessor: processor.NewWalletProcessor(repos.walletRepo),
|
walletProcessor: processor.NewWalletProcessor(repos.walletRepo),
|
||||||
loyaltySettingsProcessor: loyaltySettingsProcessor,
|
loyaltySettingsProcessor: loyaltySettingsProcessor,
|
||||||
earningProcessor: earningProcessor,
|
earningProcessor: earningProcessor,
|
||||||
|
customerPinProcessor: customerPinProcessor,
|
||||||
walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager),
|
walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,6 +498,7 @@ type services struct {
|
|||||||
cashAdvanceService *service.CashAdvanceServiceImpl
|
cashAdvanceService *service.CashAdvanceServiceImpl
|
||||||
walletAdminService *service.WalletAdminServiceImpl
|
walletAdminService *service.WalletAdminServiceImpl
|
||||||
loyaltySettingsService *service.LoyaltySettingsServiceImpl
|
loyaltySettingsService *service.LoyaltySettingsServiceImpl
|
||||||
|
customerPinService *service.CustomerPinServiceImpl
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
|
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
|
||||||
@@ -576,6 +582,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
|||||||
cashAdvanceService: service.NewCashAdvanceService(processors.cashAdvanceProcessor),
|
cashAdvanceService: service.NewCashAdvanceService(processors.cashAdvanceProcessor),
|
||||||
walletAdminService: service.NewWalletAdminService(processors.walletAdminProcessor),
|
walletAdminService: service.NewWalletAdminService(processors.walletAdminProcessor),
|
||||||
loyaltySettingsService: service.NewLoyaltySettingsService(processors.loyaltySettingsProcessor),
|
loyaltySettingsService: service.NewLoyaltySettingsService(processors.loyaltySettingsProcessor),
|
||||||
|
customerPinService: service.NewCustomerPinService(processors.customerPinProcessor),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ const (
|
|||||||
ValidationErrorCode = "304"
|
ValidationErrorCode = "304"
|
||||||
InvalidFieldErrorCode = "305"
|
InvalidFieldErrorCode = "305"
|
||||||
NotFoundErrorCode = "404"
|
NotFoundErrorCode = "404"
|
||||||
|
// PIN outcomes the customer app tells apart (docs/prd-point-coin.md §9).
|
||||||
|
PinNotSetErrorCode = "PIN_NOT_SET"
|
||||||
|
PinInvalidErrorCode = "PIN_INVALID"
|
||||||
|
PinLockedErrorCode = "PIN_LOCKED"
|
||||||
|
TransferBlockedErrorCode = "TRANSFER_BLOCKED"
|
||||||
|
TooManyRequestsErrorCode = "429"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -65,15 +71,21 @@ const (
|
|||||||
CashAdvanceServiceEntity = "cash_advance_service"
|
CashAdvanceServiceEntity = "cash_advance_service"
|
||||||
WalletServiceEntity = "wallet_service"
|
WalletServiceEntity = "wallet_service"
|
||||||
LoyaltySettingsServiceEntity = "loyalty_settings_service"
|
LoyaltySettingsServiceEntity = "loyalty_settings_service"
|
||||||
|
CustomerPinServiceEntity = "customer_pin_service"
|
||||||
)
|
)
|
||||||
|
|
||||||
var HttpErrorMap = map[string]int{
|
var HttpErrorMap = map[string]int{
|
||||||
InternalServerErrorCode: http.StatusInternalServerError,
|
InternalServerErrorCode: http.StatusInternalServerError,
|
||||||
MissingFieldErrorCode: http.StatusBadRequest,
|
MissingFieldErrorCode: http.StatusBadRequest,
|
||||||
MalformedFieldErrorCode: http.StatusBadRequest,
|
MalformedFieldErrorCode: http.StatusBadRequest,
|
||||||
ValidationErrorCode: http.StatusBadRequest,
|
ValidationErrorCode: http.StatusBadRequest,
|
||||||
InvalidFieldErrorCode: http.StatusBadRequest,
|
InvalidFieldErrorCode: http.StatusBadRequest,
|
||||||
NotFoundErrorCode: http.StatusNotFound,
|
NotFoundErrorCode: http.StatusNotFound,
|
||||||
|
PinNotSetErrorCode: http.StatusForbidden,
|
||||||
|
PinInvalidErrorCode: http.StatusBadRequest,
|
||||||
|
PinLockedErrorCode: http.StatusLocked,
|
||||||
|
TransferBlockedErrorCode: http.StatusForbidden,
|
||||||
|
TooManyRequestsErrorCode: http.StatusTooManyRequests,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error messages
|
// Error messages
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package contract
|
||||||
|
|
||||||
|
// Requests of /customer/pin and /marketing/customers/:id/pin (docs/prd-point-coin.md
|
||||||
|
// F11). PINs are strings so a leading zero is kept.
|
||||||
|
|
||||||
|
type RequestPinOtpRequest struct {
|
||||||
|
// pin_setup or pin_reset.
|
||||||
|
Purpose string `json:"purpose" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateCustomerPinRequest struct {
|
||||||
|
OtpToken string `json:"otp_token" binding:"required"`
|
||||||
|
OtpCode string `json:"otp_code" binding:"required"`
|
||||||
|
Pin string `json:"pin" binding:"required"`
|
||||||
|
ConfirmPin string `json:"confirm_pin" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChangeCustomerPinRequest struct {
|
||||||
|
OldPin string `json:"old_pin" binding:"required"`
|
||||||
|
Pin string `json:"pin" binding:"required"`
|
||||||
|
ConfirmPin string `json:"confirm_pin" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResetCustomerPinRequest = CreateCustomerPinRequest
|
||||||
|
|
||||||
|
type RemoveCustomerPinRequest struct {
|
||||||
|
Reason string `json:"reason" binding:"required"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/appcontext"
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
|
"apskel-pos-be/internal/contract"
|
||||||
|
"apskel-pos-be/internal/models"
|
||||||
|
"apskel-pos-be/internal/service"
|
||||||
|
"apskel-pos-be/internal/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CustomerPinHandler serves /customer/pin and the dashboard's PIN endpoints
|
||||||
|
// (docs/prd-point-coin.md F11). Request bodies hold PINs, so nothing here logs a body,
|
||||||
|
// and binding errors are reported without the values sent.
|
||||||
|
type CustomerPinHandler struct {
|
||||||
|
pinService service.CustomerPinService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCustomerPinHandler(pinService service.CustomerPinService) *CustomerPinHandler {
|
||||||
|
return &CustomerPinHandler{pinService: pinService}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerPinHandler) Status(c *gin.Context) {
|
||||||
|
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::Status")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
util.HandleResponse(c.Writer, c.Request, h.pinService.Status(c.Request.Context(), customerID), "CustomerPinHandler::Status")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerPinHandler) RequestOtp(c *gin.Context) {
|
||||||
|
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::RequestOtp")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req contract.RequestPinOtpRequest
|
||||||
|
if !bindPinRequest(c, &req, "CustomerPinHandler::RequestOtp") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
util.HandleResponse(c.Writer, c.Request, h.pinService.RequestOtp(c.Request.Context(), customerID, &req), "CustomerPinHandler::RequestOtp")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerPinHandler) CreatePin(c *gin.Context) {
|
||||||
|
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::CreatePin")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req contract.CreateCustomerPinRequest
|
||||||
|
if !bindPinRequest(c, &req, "CustomerPinHandler::CreatePin") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
util.HandleResponse(c.Writer, c.Request, h.pinService.CreatePin(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::CreatePin")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerPinHandler) ChangePin(c *gin.Context) {
|
||||||
|
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::ChangePin")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req contract.ChangeCustomerPinRequest
|
||||||
|
if !bindPinRequest(c, &req, "CustomerPinHandler::ChangePin") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
util.HandleResponse(c.Writer, c.Request, h.pinService.ChangePin(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::ChangePin")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CustomerPinHandler) ResetPin(c *gin.Context) {
|
||||||
|
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::ResetPin")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req contract.ResetCustomerPinRequest
|
||||||
|
if !bindPinRequest(c, &req, "CustomerPinHandler::ResetPin") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
util.HandleResponse(c.Writer, c.Request, h.pinService.ResetPin(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::ResetPin")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemovePin is DELETE /marketing/customers/:id/pin.
|
||||||
|
func (h *CustomerPinHandler) RemovePin(c *gin.Context) {
|
||||||
|
customerID, ok := parseUUIDParam(c, "id", "CustomerPinHandler::RemovePin")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req contract.RemoveCustomerPinRequest
|
||||||
|
if !bindPinRequest(c, &req, "CustomerPinHandler::RemovePin") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
util.HandleResponse(c.Writer, c.Request, h.pinService.RemovePin(ctx, appcontext.FromGinContext(ctx), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::RemovePin")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSecurityEvents is GET /marketing/customers/:id/security-events.
|
||||||
|
func (h *CustomerPinHandler) ListSecurityEvents(c *gin.Context) {
|
||||||
|
customerID, ok := parseUUIDParam(c, "id", "CustomerPinHandler::ListSecurityEvents")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
util.HandleResponse(c.Writer, c.Request, h.pinService.ListSecurityEvents(ctx, appcontext.FromGinContext(ctx), customerID, page, limit), "CustomerPinHandler::ListSecurityEvents")
|
||||||
|
}
|
||||||
|
|
||||||
|
// bindPinRequest binds a JSON body. The error it reports names what is wrong, never the
|
||||||
|
// values, since those can be PINs.
|
||||||
|
func bindPinRequest(c *gin.Context, req interface{}, method string) bool {
|
||||||
|
if err := c.ShouldBindJSON(req); err != nil {
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||||
|
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "invalid request body: required fields are missing or have the wrong type"),
|
||||||
|
}), method)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// customerIDFromGin reads the customer set by CustomerAuthMiddleware.
|
||||||
|
func customerIDFromGin(c *gin.Context, method string) (uuid.UUID, bool) {
|
||||||
|
raw, _ := c.Get("customer_id")
|
||||||
|
s, _ := raw.(string)
|
||||||
|
id, err := uuid.Parse(s)
|
||||||
|
if err != nil {
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||||
|
contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Customer ID not found"),
|
||||||
|
}), method)
|
||||||
|
return uuid.Nil, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func pinRequestInfo(c *gin.Context) models.CustomerPinRequestInfo {
|
||||||
|
return models.CustomerPinRequestInfo{IPAddress: c.ClientIP(), UserAgent: c.Request.UserAgent()}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CustomerPinStatus is GET /customer/pin/status.
|
||||||
|
type CustomerPinStatus struct {
|
||||||
|
HasPin bool `json:"has_pin"`
|
||||||
|
LockedUntil *time.Time `json:"locked_until"`
|
||||||
|
TransferBlockedUntil *time.Time `json:"transfer_blocked_until"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomerPinOtp is what POST /customer/pin/otp returns: the token to send back with
|
||||||
|
// the code the customer received.
|
||||||
|
type CustomerPinOtp struct {
|
||||||
|
Purpose string `json:"purpose"`
|
||||||
|
OtpToken string `json:"otp_token"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomerSecurityEventView is one row of GET /marketing/customers/:id/security-events.
|
||||||
|
type CustomerSecurityEventView struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Event string `json:"event"`
|
||||||
|
ActorUser *uuid.UUID `json:"actor_user,omitempty"`
|
||||||
|
Reason *string `json:"reason,omitempty"`
|
||||||
|
IPAddress *string `json:"ip_address,omitempty"`
|
||||||
|
UserAgent *string `json:"user_agent,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomerPinRequestInfo is where a PIN request came from, for the security log.
|
||||||
|
type CustomerPinRequestInfo struct {
|
||||||
|
IPAddress string
|
||||||
|
UserAgent string
|
||||||
|
}
|
||||||
@@ -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)
|
CreateOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error)
|
||||||
ResendOtpSession(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
|
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
|
ValidateOtpCode(code string) bool
|
||||||
ValidateOtpSession(ctx context.Context, token string, code string) (*entities.OtpSession, error)
|
ValidateOtpSession(ctx context.Context, token string, code string) (*entities.OtpSession, error)
|
||||||
InvalidateOtpSession(ctx context.Context, token string) error
|
InvalidateOtpSession(ctx context.Context, token string) error
|
||||||
@@ -133,6 +135,10 @@ func (p *otpProcessor) SendOtpViaWhatsApp(phoneNumber string, otpCode string, pu
|
|||||||
switch purpose {
|
switch purpose {
|
||||||
case "login":
|
case "login":
|
||||||
message = fmt.Sprintf("Kode OTP untuk login kamu adalah %s. Berlaku 5 menit.", otpCode)
|
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":
|
case "registration":
|
||||||
message = fmt.Sprintf("Kode OTP untuk registrasi kamu adalah %s. Berlaku 5 menit.", otpCode)
|
message = fmt.Sprintf("Kode OTP untuk registrasi kamu adalah %s. Berlaku 5 menit.", otpCode)
|
||||||
default:
|
default:
|
||||||
@@ -236,3 +242,10 @@ func (p *otpProcessor) formatPhoneNumber(phoneNumber string) string {
|
|||||||
|
|
||||||
return digits
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrPinCustomerNotFound means the customer does not exist.
|
||||||
|
var ErrPinCustomerNotFound = errors.New("pin: customer not found")
|
||||||
|
|
||||||
|
// CustomerPinState is a customer's PIN and what guards it. It lives in the customers
|
||||||
|
// table but is read and written only here, never through the Customer entity, so the
|
||||||
|
// hash cannot end up in a customer response.
|
||||||
|
type CustomerPinState struct {
|
||||||
|
CustomerID uuid.UUID
|
||||||
|
OrganizationID uuid.UUID
|
||||||
|
PhoneNumber *string
|
||||||
|
BirthDate *time.Time
|
||||||
|
PinHash *string
|
||||||
|
PinSetAt *time.Time
|
||||||
|
FailedAttempts int
|
||||||
|
LockedUntil *time.Time
|
||||||
|
TransferBlockedUntil *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomerSecurityEvent is one row of the PIN security log.
|
||||||
|
type CustomerSecurityEvent struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
CustomerID uuid.UUID
|
||||||
|
Event string
|
||||||
|
ActorUser *uuid.UUID
|
||||||
|
Reason *string
|
||||||
|
IPAddress *string
|
||||||
|
UserAgent *string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomerPinRepository stores customer PINs and their security log
|
||||||
|
// (docs/prd-point-coin.md F11).
|
||||||
|
type CustomerPinRepository interface {
|
||||||
|
GetState(ctx context.Context, customerID uuid.UUID) (*CustomerPinState, error)
|
||||||
|
// SetPin stores a new PIN hash, clears the failure counter and any lock, and sets
|
||||||
|
// or clears the transfer hold.
|
||||||
|
SetPin(ctx context.Context, customerID uuid.UUID, hash string, transferBlockedUntil *time.Time) error
|
||||||
|
// RemovePin deletes the PIN, so the customer has to create a new one through OTP.
|
||||||
|
RemovePin(ctx context.Context, customerID uuid.UUID) error
|
||||||
|
// RecordFailure adds one wrong attempt in a single statement, so wrong attempts
|
||||||
|
// made at the same time all count. A lock that has already run out starts the
|
||||||
|
// count again. When the count reaches maxAttempts the PIN is locked until
|
||||||
|
// lockUntil. It returns the count and lock after the update.
|
||||||
|
RecordFailure(ctx context.Context, customerID uuid.UUID, maxAttempts int, now, lockUntil time.Time) (int, *time.Time, error)
|
||||||
|
ClearFailures(ctx context.Context, customerID uuid.UUID) error
|
||||||
|
|
||||||
|
InsertEvent(ctx context.Context, event CustomerSecurityEvent) error
|
||||||
|
// ListEvents returns a page of the customer's log, newest first, and the total.
|
||||||
|
ListEvents(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]CustomerSecurityEvent, int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type customerPinRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCustomerPinRepository(db *gorm.DB) CustomerPinRepository {
|
||||||
|
return &customerPinRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *customerPinRepository) GetState(ctx context.Context, customerID uuid.UUID) (*CustomerPinState, error) {
|
||||||
|
var rows []struct {
|
||||||
|
CustomerID string
|
||||||
|
OrganizationID string
|
||||||
|
PhoneNumber *string
|
||||||
|
BirthDate *time.Time
|
||||||
|
PinHash *string
|
||||||
|
PinSetAt *time.Time
|
||||||
|
PinFailedAttempts int
|
||||||
|
PinLockedUntil *time.Time
|
||||||
|
TransferBlockedUntil *time.Time
|
||||||
|
}
|
||||||
|
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||||
|
SELECT id::text AS customer_id, organization_id::text AS organization_id,
|
||||||
|
COALESCE(phone_number, phone) AS phone_number, birth_date,
|
||||||
|
pin_hash, pin_set_at, pin_failed_attempts, pin_locked_until, transfer_blocked_until
|
||||||
|
FROM customers WHERE id = ? LIMIT 1`, customerID).Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read customer PIN: %w", err)
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil, ErrPinCustomerNotFound
|
||||||
|
}
|
||||||
|
row := rows[0]
|
||||||
|
state := &CustomerPinState{
|
||||||
|
PhoneNumber: row.PhoneNumber,
|
||||||
|
BirthDate: row.BirthDate,
|
||||||
|
PinHash: row.PinHash,
|
||||||
|
PinSetAt: row.PinSetAt,
|
||||||
|
FailedAttempts: row.PinFailedAttempts,
|
||||||
|
LockedUntil: row.PinLockedUntil,
|
||||||
|
TransferBlockedUntil: row.TransferBlockedUntil,
|
||||||
|
}
|
||||||
|
state.CustomerID, _ = uuid.Parse(row.CustomerID)
|
||||||
|
state.OrganizationID, _ = uuid.Parse(row.OrganizationID)
|
||||||
|
return state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *customerPinRepository) SetPin(ctx context.Context, customerID uuid.UUID, hash string, transferBlockedUntil *time.Time) error {
|
||||||
|
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
|
||||||
|
UPDATE customers SET pin_hash = ?, pin_set_at = NOW(), pin_failed_attempts = 0,
|
||||||
|
pin_locked_until = NULL, transfer_blocked_until = ?, updated_at = NOW()
|
||||||
|
WHERE id = ?`, hash, transferBlockedUntil, customerID)
|
||||||
|
if result.Error != nil {
|
||||||
|
return fmt.Errorf("failed to store customer PIN: %w", result.Error)
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return ErrPinCustomerNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *customerPinRepository) RemovePin(ctx context.Context, customerID uuid.UUID) error {
|
||||||
|
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
|
||||||
|
UPDATE customers SET pin_hash = NULL, pin_set_at = NULL, pin_failed_attempts = 0,
|
||||||
|
pin_locked_until = NULL, updated_at = NOW()
|
||||||
|
WHERE id = ?`, customerID)
|
||||||
|
if result.Error != nil {
|
||||||
|
return fmt.Errorf("failed to remove customer PIN: %w", result.Error)
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return ErrPinCustomerNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *customerPinRepository) RecordFailure(ctx context.Context, customerID uuid.UUID, maxAttempts int, now, lockUntil time.Time) (int, *time.Time, error) {
|
||||||
|
var rows []struct {
|
||||||
|
PinFailedAttempts int
|
||||||
|
PinLockedUntil *time.Time
|
||||||
|
}
|
||||||
|
// When an earlier lock has run out, this attempt is the first of a new series.
|
||||||
|
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||||
|
UPDATE customers SET
|
||||||
|
pin_failed_attempts = CASE
|
||||||
|
WHEN pin_locked_until IS NOT NULL AND pin_locked_until <= @now THEN 1
|
||||||
|
ELSE pin_failed_attempts + 1 END,
|
||||||
|
pin_locked_until = CASE
|
||||||
|
WHEN pin_locked_until IS NOT NULL AND pin_locked_until <= @now THEN NULL
|
||||||
|
WHEN pin_failed_attempts + 1 >= @max THEN @lock
|
||||||
|
ELSE pin_locked_until END
|
||||||
|
WHERE id = @id
|
||||||
|
RETURNING pin_failed_attempts, pin_locked_until`,
|
||||||
|
map[string]interface{}{"now": now, "max": maxAttempts, "lock": lockUntil, "id": customerID}).
|
||||||
|
Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, fmt.Errorf("failed to record a wrong PIN: %w", err)
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return 0, nil, ErrPinCustomerNotFound
|
||||||
|
}
|
||||||
|
return rows[0].PinFailedAttempts, rows[0].PinLockedUntil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *customerPinRepository) ClearFailures(ctx context.Context, customerID uuid.UUID) error {
|
||||||
|
return DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
|
||||||
|
UPDATE customers SET pin_failed_attempts = 0, pin_locked_until = NULL
|
||||||
|
WHERE id = ? AND (pin_failed_attempts <> 0 OR pin_locked_until IS NOT NULL)`, customerID).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *customerPinRepository) InsertEvent(ctx context.Context, event CustomerSecurityEvent) error {
|
||||||
|
err := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
|
||||||
|
INSERT INTO customer_security_events (customer_id, event, actor_user, reason, ip_address, user_agent)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
event.CustomerID, event.Event, event.ActorUser, event.Reason, event.IPAddress, event.UserAgent).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to record security event: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *customerPinRepository) ListEvents(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]CustomerSecurityEvent, int64, error) {
|
||||||
|
db := DBFromContext(ctx, r.db).WithContext(ctx)
|
||||||
|
var total int64
|
||||||
|
if err := db.Table("customer_security_events").Where("customer_id = ?", customerID).Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("failed to count security events: %w", err)
|
||||||
|
}
|
||||||
|
var rows []struct {
|
||||||
|
ID string
|
||||||
|
CustomerID string
|
||||||
|
Event string
|
||||||
|
ActorUser *string
|
||||||
|
Reason *string
|
||||||
|
IPAddress *string
|
||||||
|
UserAgent *string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
err := db.Raw(`
|
||||||
|
SELECT id::text AS id, customer_id::text AS customer_id, event, actor_user::text AS actor_user,
|
||||||
|
reason, ip_address, user_agent, created_at
|
||||||
|
FROM customer_security_events WHERE customer_id = ?
|
||||||
|
ORDER BY created_at DESC, id DESC OFFSET ? LIMIT ?`, customerID, offset, limit).Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("failed to list security events: %w", err)
|
||||||
|
}
|
||||||
|
events := make([]CustomerSecurityEvent, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
e := CustomerSecurityEvent{Event: row.Event, Reason: row.Reason, IPAddress: row.IPAddress, UserAgent: row.UserAgent, CreatedAt: row.CreatedAt}
|
||||||
|
e.ID, _ = uuid.Parse(row.ID)
|
||||||
|
e.CustomerID, _ = uuid.Parse(row.CustomerID)
|
||||||
|
if row.ActorUser != nil {
|
||||||
|
if id, err := uuid.Parse(*row.ActorUser); err == nil {
|
||||||
|
e.ActorUser = &id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
events = append(events, e)
|
||||||
|
}
|
||||||
|
return events, total, nil
|
||||||
|
}
|
||||||
@@ -56,12 +56,13 @@ type Router struct {
|
|||||||
cashAdvanceHandler *handler.CashAdvanceHandler
|
cashAdvanceHandler *handler.CashAdvanceHandler
|
||||||
walletAdminHandler *handler.WalletAdminHandler
|
walletAdminHandler *handler.WalletAdminHandler
|
||||||
loyaltySettingsHandler *handler.LoyaltySettingsHandler
|
loyaltySettingsHandler *handler.LoyaltySettingsHandler
|
||||||
|
customerPinHandler *handler.CustomerPinHandler
|
||||||
authMiddleware *middleware.AuthMiddleware
|
authMiddleware *middleware.AuthMiddleware
|
||||||
customerAuthMiddleware *middleware.CustomerAuthMiddleware
|
customerAuthMiddleware *middleware.CustomerAuthMiddleware
|
||||||
redisClient *redis.Client
|
redisClient *redis.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, redisClient *redis.Client) *Router {
|
func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, customerPinService service.CustomerPinService, redisClient *redis.Client) *Router {
|
||||||
|
|
||||||
return &Router{
|
return &Router{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
@@ -109,6 +110,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer
|
|||||||
cashAdvanceHandler: handler.NewCashAdvanceHandler(cashAdvanceService, cashAdvanceValidator),
|
cashAdvanceHandler: handler.NewCashAdvanceHandler(cashAdvanceService, cashAdvanceValidator),
|
||||||
walletAdminHandler: handler.NewWalletAdminHandler(walletAdminService, walletValidator),
|
walletAdminHandler: handler.NewWalletAdminHandler(walletAdminService, walletValidator),
|
||||||
loyaltySettingsHandler: handler.NewLoyaltySettingsHandler(loyaltySettingsService),
|
loyaltySettingsHandler: handler.NewLoyaltySettingsHandler(loyaltySettingsService),
|
||||||
|
customerPinHandler: handler.NewCustomerPinHandler(customerPinService),
|
||||||
redisClient: redisClient,
|
redisClient: redisClient,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,6 +164,12 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
customer.GET("/tokens", r.customerPointsHandler.GetCustomerTokens)
|
customer.GET("/tokens", r.customerPointsHandler.GetCustomerTokens)
|
||||||
customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet)
|
customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet)
|
||||||
customer.GET("/wallet/transactions", r.customerPointsHandler.GetCustomerWalletTransactions)
|
customer.GET("/wallet/transactions", r.customerPointsHandler.GetCustomerWalletTransactions)
|
||||||
|
// PIN that approves moving EnakPoint and EnakCoin (docs/prd-point-coin.md F11)
|
||||||
|
customer.GET("/pin/status", r.customerPinHandler.Status)
|
||||||
|
customer.POST("/pin/otp", r.customerPinHandler.RequestOtp)
|
||||||
|
customer.POST("/pin", r.customerPinHandler.CreatePin)
|
||||||
|
customer.PUT("/pin", r.customerPinHandler.ChangePin)
|
||||||
|
customer.POST("/pin/reset", r.customerPinHandler.ResetPin)
|
||||||
customer.GET("/games", r.customerPointsHandler.GetCustomerGames)
|
customer.GET("/games", r.customerPointsHandler.GetCustomerGames)
|
||||||
customer.GET("/ferris-wheel", r.customerPointsHandler.GetFerrisWheelGame)
|
customer.GET("/ferris-wheel", r.customerPointsHandler.GetFerrisWheelGame)
|
||||||
customer.POST("/spin", r.spinGameHandler.PlaySpinGame)
|
customer.POST("/spin", r.spinGameHandler.PlaySpinGame)
|
||||||
@@ -624,6 +632,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
{
|
{
|
||||||
marketingCustomers.GET("/:id/wallet", r.walletAdminHandler.GetCustomerWallet)
|
marketingCustomers.GET("/:id/wallet", r.walletAdminHandler.GetCustomerWallet)
|
||||||
marketingCustomers.POST("/:id/wallet/adjust", r.authMiddleware.RequireLoyaltyManager(), r.walletAdminHandler.AdjustCustomerWallet)
|
marketingCustomers.POST("/:id/wallet/adjust", r.authMiddleware.RequireLoyaltyManager(), r.walletAdminHandler.AdjustCustomerWallet)
|
||||||
|
marketingCustomers.DELETE("/:id/pin", r.authMiddleware.RequireLoyaltyManager(), r.customerPinHandler.RemovePin)
|
||||||
|
marketingCustomers.GET("/:id/security-events", r.customerPinHandler.ListSecurityEvents)
|
||||||
}
|
}
|
||||||
|
|
||||||
campaignRules := gamification.Group("/campaign-rules")
|
campaignRules := gamification.Group("/campaign-rules")
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ func TestAllRoutesRegister(t *testing.T) {
|
|||||||
"POST /api/v1/marketing/customers/:id/wallet/adjust",
|
"POST /api/v1/marketing/customers/:id/wallet/adjust",
|
||||||
"GET /api/v1/outlets/:outlet_id/loyalty-settings",
|
"GET /api/v1/outlets/:outlet_id/loyalty-settings",
|
||||||
"PUT /api/v1/outlets/:outlet_id/loyalty-settings",
|
"PUT /api/v1/outlets/:outlet_id/loyalty-settings",
|
||||||
|
"GET /api/v1/customer/pin/status",
|
||||||
|
"POST /api/v1/customer/pin/otp",
|
||||||
|
"POST /api/v1/customer/pin",
|
||||||
|
"PUT /api/v1/customer/pin",
|
||||||
|
"POST /api/v1/customer/pin/reset",
|
||||||
|
"DELETE /api/v1/marketing/customers/:id/pin",
|
||||||
|
"GET /api/v1/marketing/customers/:id/security-events",
|
||||||
} {
|
} {
|
||||||
assert.True(t, registered[want], want)
|
assert.True(t, registered[want], want)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/appcontext"
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
|
"apskel-pos-be/internal/contract"
|
||||||
|
"apskel-pos-be/internal/models"
|
||||||
|
"apskel-pos-be/internal/processor"
|
||||||
|
"apskel-pos-be/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CustomerPinService serves the customer's PIN (docs/prd-point-coin.md F11) and the
|
||||||
|
// dashboard's view of it.
|
||||||
|
type CustomerPinService interface {
|
||||||
|
Status(ctx context.Context, customerID uuid.UUID) *contract.Response
|
||||||
|
RequestOtp(ctx context.Context, customerID uuid.UUID, req *contract.RequestPinOtpRequest) *contract.Response
|
||||||
|
CreatePin(ctx context.Context, customerID uuid.UUID, req *contract.CreateCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response
|
||||||
|
ChangePin(ctx context.Context, customerID uuid.UUID, req *contract.ChangeCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response
|
||||||
|
ResetPin(ctx context.Context, customerID uuid.UUID, req *contract.ResetCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response
|
||||||
|
|
||||||
|
RemovePin(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, req *contract.RemoveCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response
|
||||||
|
ListSecurityEvents(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, page, limit int) *contract.Response
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomerPinServiceImpl struct {
|
||||||
|
pins *processor.CustomerPinProcessor
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCustomerPinService(pins *processor.CustomerPinProcessor) *CustomerPinServiceImpl {
|
||||||
|
return &CustomerPinServiceImpl{pins: pins}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomerPinServiceImpl) Status(ctx context.Context, customerID uuid.UUID) *contract.Response {
|
||||||
|
status, err := s.pins.Status(ctx, customerID)
|
||||||
|
if err != nil {
|
||||||
|
return PinErrorResponse(err)
|
||||||
|
}
|
||||||
|
return contract.BuildSuccessResponse(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomerPinServiceImpl) RequestOtp(ctx context.Context, customerID uuid.UUID, req *contract.RequestPinOtpRequest) *contract.Response {
|
||||||
|
otp, err := s.pins.RequestOtp(ctx, customerID, req.Purpose)
|
||||||
|
if err != nil {
|
||||||
|
return PinErrorResponse(err)
|
||||||
|
}
|
||||||
|
return contract.BuildSuccessResponse(otp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomerPinServiceImpl) CreatePin(ctx context.Context, customerID uuid.UUID, req *contract.CreateCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response {
|
||||||
|
if err := s.pins.CreatePin(ctx, customerID, req.OtpToken, req.OtpCode, req.Pin, req.ConfirmPin, info); err != nil {
|
||||||
|
return PinErrorResponse(err)
|
||||||
|
}
|
||||||
|
return s.Status(ctx, customerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomerPinServiceImpl) ChangePin(ctx context.Context, customerID uuid.UUID, req *contract.ChangeCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response {
|
||||||
|
if err := s.pins.ChangePin(ctx, customerID, req.OldPin, req.Pin, req.ConfirmPin, info); err != nil {
|
||||||
|
return PinErrorResponse(err)
|
||||||
|
}
|
||||||
|
return s.Status(ctx, customerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomerPinServiceImpl) ResetPin(ctx context.Context, customerID uuid.UUID, req *contract.ResetCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response {
|
||||||
|
if err := s.pins.ResetPin(ctx, customerID, req.OtpToken, req.OtpCode, req.Pin, req.ConfirmPin, info); err != nil {
|
||||||
|
return PinErrorResponse(err)
|
||||||
|
}
|
||||||
|
return s.Status(ctx, customerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomerPinServiceImpl) RemovePin(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, req *contract.RemoveCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response {
|
||||||
|
if err := s.pins.RemovePinByAdmin(ctx, apctx.OrganizationID, customerID, apctx.UserID, req.Reason, info); err != nil {
|
||||||
|
return PinErrorResponse(err)
|
||||||
|
}
|
||||||
|
return contract.BuildSuccessResponse(map[string]interface{}{"message": "PIN removed; the customer has to create a new one"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomerPinServiceImpl) ListSecurityEvents(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, page, limit int) *contract.Response {
|
||||||
|
events, err := s.pins.ListEvents(ctx, apctx.OrganizationID, customerID, page, limit)
|
||||||
|
if err != nil {
|
||||||
|
return PinErrorResponse(err)
|
||||||
|
}
|
||||||
|
return contract.BuildSuccessResponse(events)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PinErrorResponse turns an error from a PIN-guarded action into a response the apps
|
||||||
|
// can act on. A *processor.PinError keeps its code (PIN_NOT_SET, PIN_INVALID,
|
||||||
|
// PIN_LOCKED, TRANSFER_BLOCKED) and puts the attempts left or the time it lifts in the
|
||||||
|
// response data. Other errors map to a validation or server error.
|
||||||
|
func PinErrorResponse(err error) *contract.Response {
|
||||||
|
var pinErr *processor.PinError
|
||||||
|
if errors.As(err, &pinErr) {
|
||||||
|
data := map[string]interface{}{"code": pinErr.Code}
|
||||||
|
switch pinErr.Code {
|
||||||
|
case processor.PinErrInvalid:
|
||||||
|
data["remaining_attempts"] = pinErr.RemainingAttempts
|
||||||
|
case processor.PinErrLocked:
|
||||||
|
data["locked_until"] = pinErr.Until
|
||||||
|
case processor.PinErrTransferBlocked:
|
||||||
|
data["transfer_blocked_until"] = pinErr.Until
|
||||||
|
}
|
||||||
|
return &contract.Response{
|
||||||
|
Success: false,
|
||||||
|
Data: data,
|
||||||
|
Errors: []*contract.ResponseError{contract.NewResponseError(pinErr.Code, constants.CustomerPinServiceEntity, pinErr.Error())},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
code := constants.InternalServerErrorCode
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, repository.ErrPinCustomerNotFound):
|
||||||
|
code = constants.NotFoundErrorCode
|
||||||
|
case errors.Is(err, processor.ErrPinOtpTooSoon):
|
||||||
|
code = constants.TooManyRequestsErrorCode
|
||||||
|
case errors.Is(err, processor.ErrInvalidPinInput),
|
||||||
|
errors.Is(err, processor.ErrPinAlreadySet),
|
||||||
|
errors.Is(err, processor.ErrPinOtpInvalid),
|
||||||
|
errors.Is(err, processor.ErrPinNoPhone):
|
||||||
|
code = constants.ValidationErrorCode
|
||||||
|
}
|
||||||
|
return contract.BuildErrorResponse([]*contract.ResponseError{
|
||||||
|
contract.NewResponseError(code, constants.CustomerPinServiceEntity, err.Error()),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/processor"
|
||||||
|
"apskel-pos-be/internal/repository"
|
||||||
|
"apskel-pos-be/internal/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPinErrorResponse(t *testing.T) {
|
||||||
|
until := time.Date(2026, 9, 30, 12, 30, 0, 0, time.UTC)
|
||||||
|
for name, c := range map[string]struct {
|
||||||
|
err error
|
||||||
|
code string
|
||||||
|
status int
|
||||||
|
data map[string]interface{}
|
||||||
|
}{
|
||||||
|
"not set": {&processor.PinError{Code: processor.PinErrNotSet}, "PIN_NOT_SET", http.StatusForbidden, map[string]interface{}{"code": "PIN_NOT_SET"}},
|
||||||
|
"invalid": {fmt.Errorf("pay: %w", &processor.PinError{Code: processor.PinErrInvalid, RemainingAttempts: 2}), "PIN_INVALID", http.StatusBadRequest, map[string]interface{}{"code": "PIN_INVALID", "remaining_attempts": 2}},
|
||||||
|
"locked": {&processor.PinError{Code: processor.PinErrLocked, Until: &until}, "PIN_LOCKED", http.StatusLocked, map[string]interface{}{"code": "PIN_LOCKED", "locked_until": &until}},
|
||||||
|
"transfer": {&processor.PinError{Code: processor.PinErrTransferBlocked, Until: &until}, "TRANSFER_BLOCKED", http.StatusForbidden, map[string]interface{}{"code": "TRANSFER_BLOCKED", "transfer_blocked_until": &until}},
|
||||||
|
} {
|
||||||
|
resp := PinErrorResponse(c.err)
|
||||||
|
assert.False(t, resp.Success, name)
|
||||||
|
assert.Equal(t, c.code, resp.Errors[0].Code, name)
|
||||||
|
assert.Equal(t, c.status, util.MapErrorCodeToHttpStatus(resp.Errors[0].Code), name)
|
||||||
|
assert.Equal(t, c.data, resp.Data, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, c := range map[string]struct {
|
||||||
|
err error
|
||||||
|
status int
|
||||||
|
}{
|
||||||
|
"weak PIN": {fmt.Errorf("%w: too easy", processor.ErrInvalidPinInput), http.StatusBadRequest},
|
||||||
|
"bad OTP": {processor.ErrPinOtpInvalid, http.StatusBadRequest},
|
||||||
|
"already set": {processor.ErrPinAlreadySet, http.StatusBadRequest},
|
||||||
|
"too soon": {processor.ErrPinOtpTooSoon, http.StatusTooManyRequests},
|
||||||
|
"no customer": {repository.ErrPinCustomerNotFound, http.StatusNotFound},
|
||||||
|
"anything else": {fmt.Errorf("db down"), http.StatusInternalServerError},
|
||||||
|
} {
|
||||||
|
resp := PinErrorResponse(c.err)
|
||||||
|
assert.Equal(t, c.status, util.MapErrorCodeToHttpStatus(resp.Errors[0].Code), name)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
DROP TABLE IF EXISTS customer_security_events;
|
||||||
|
|
||||||
|
ALTER TABLE customers
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_customers_pin_failed_attempts,
|
||||||
|
DROP COLUMN IF EXISTS transfer_blocked_until,
|
||||||
|
DROP COLUMN IF EXISTS pin_locked_until,
|
||||||
|
DROP COLUMN IF EXISTS pin_failed_attempts,
|
||||||
|
DROP COLUMN IF EXISTS pin_set_at,
|
||||||
|
DROP COLUMN IF EXISTS pin_hash;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- Customer PIN (docs/prd-point-coin.md F11, K8). A 6-digit PIN, separate from the
|
||||||
|
-- login password, approves everything that moves EnakPoint or EnakCoin on the
|
||||||
|
-- customer's request. Only its bcrypt hash is stored.
|
||||||
|
ALTER TABLE customers
|
||||||
|
ADD COLUMN pin_hash VARCHAR(255),
|
||||||
|
ADD COLUMN pin_set_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
-- Kept in the database, not a cache, so it cannot be dodged by waiting for a cache
|
||||||
|
-- to expire or by hitting another server (Q17).
|
||||||
|
ADD COLUMN pin_failed_attempts INT NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN pin_locked_until TIMESTAMP WITH TIME ZONE,
|
||||||
|
-- Outgoing transfers are held for 24 hours after a PIN reset (Q16).
|
||||||
|
ADD COLUMN transfer_blocked_until TIMESTAMP WITH TIME ZONE,
|
||||||
|
ADD CONSTRAINT chk_customers_pin_failed_attempts CHECK (pin_failed_attempts >= 0);
|
||||||
|
|
||||||
|
-- Security log of PIN events. Not a balance movement, so not in wallet_transactions.
|
||||||
|
CREATE TABLE customer_security_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
|
||||||
|
-- PIN_SET, PIN_CHANGED, PIN_RESET, PIN_FAILED, PIN_LOCKED, PIN_REMOVED_BY_ADMIN
|
||||||
|
event VARCHAR(30) NOT NULL,
|
||||||
|
-- The admin, for PIN_REMOVED_BY_ADMIN.
|
||||||
|
actor_user UUID,
|
||||||
|
reason VARCHAR(255),
|
||||||
|
ip_address VARCHAR(45),
|
||||||
|
user_agent VARCHAR(255),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT chk_customer_security_events_admin CHECK (
|
||||||
|
event <> 'PIN_REMOVED_BY_ADMIN' OR (actor_user IS NOT NULL AND reason IS NOT NULL))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_customer_security_events_customer_id_created_at ON customer_security_events(customer_id, created_at DESC);
|
||||||
Reference in New Issue
Block a user