Files
apskel-pos-backend/internal/processor/customer_auth_processor.go
T
efrilmandClaude Opus 5.5 a01e651709 fix(customer-auth): refuse a wrong login with 304 and limit attempts
A wrong password or an unknown phone number was answered 900 (HTTP 500), like a
server error, so clients could only tell them apart by the cause text. Both are
now 304 (HTTP 400) from customer_auth_service with one cause, "invalid phone
number or password", so a login does not tell which numbers have an account. A
customer who never set a password gets 304 "customer not properly registered".
Anything else stays 900.

Login is limited per phone number: 5 attempts in 15 minutes, counted in Redis
before the password is checked, so attempts sent at once all count, and for
numbers without a customer too. The sixth is refused with 429 and
data.locked_until, even with the right password, until the window ends. A
successful login starts the count again. Since the number is normalized first,
0812… and 62812… count as one. When Redis fails, logins go on unlimited and the
error is logged.

There is no limit per IP: the client IP comes from X-Forwarded-For, which anyone
can set while no trusted proxies are configured.

Tested over HTTP with fakes; the Redis commands were checked against miniredis
outside the repo, not against a real Redis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:59:27 +07:00

536 lines
17 KiB
Go

package processor
import (
"context"
"errors"
"fmt"
"strings"
"time"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
"apskel-pos-be/internal/util"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
var (
// ErrCustomerLoginInvalid means no customer has the phone number or the password is
// wrong. Which of the two is not told.
ErrCustomerLoginInvalid = errors.New("invalid phone number or password")
// ErrCustomerNotRegistered means the customer never set a password: registration
// stopped before its last step.
ErrCustomerNotRegistered = errors.New("customer not properly registered")
)
// Login attempts a phone number may make before it has to wait, and the window they
// are counted in. A successful login starts the count again.
const (
customerLoginMaxAttempts = 5
customerLoginWindow = 15 * time.Minute
)
// CustomerLoginLockedError means the phone number made too many login attempts and may
// try again at Until.
type CustomerLoginLockedError struct {
Until time.Time
}
func (e *CustomerLoginLockedError) Error() string {
return fmt.Sprintf("too many login attempts, try again after %s", e.Until.Format(time.RFC3339))
}
type CustomerAuthProcessor interface {
CheckPhoneNumber(ctx context.Context, req *contract.CheckPhoneRequest) (*models.CheckPhoneResponse, error)
StartRegistration(ctx context.Context, req *contract.RegisterStartRequest) (*models.RegisterStartResponse, error)
VerifyOtp(ctx context.Context, req *contract.RegisterVerifyOtpRequest) (*models.RegisterVerifyOtpResponse, error)
SetPassword(ctx context.Context, req *contract.RegisterSetPasswordRequest) (*models.RegisterSetPasswordResponse, error)
Login(ctx context.Context, req *contract.CustomerLoginRequest) (*models.CustomerLoginResponse, error)
ResendOtp(ctx context.Context, req *contract.ResendOtpRequest) (*models.ResendOtpResponse, error)
}
type customerAuthProcessor struct {
customerAuthRepo repository.CustomerAuthRepository
loginAttemptsRepo repository.CustomerLoginAttemptRepository
otpProcessor OtpProcessor
otpRepo repository.OtpRepository
jwtSecret string
tokenTTLMinutes int
}
func NewCustomerAuthProcessor(customerAuthRepo repository.CustomerAuthRepository, loginAttemptsRepo repository.CustomerLoginAttemptRepository, otpProcessor OtpProcessor, otpRepo repository.OtpRepository, jwtSecret string, tokenTTLMinutes int) CustomerAuthProcessor {
return &customerAuthProcessor{
customerAuthRepo: customerAuthRepo,
loginAttemptsRepo: loginAttemptsRepo,
otpProcessor: otpProcessor,
otpRepo: otpRepo,
jwtSecret: jwtSecret,
tokenTTLMinutes: tokenTTLMinutes,
}
}
func (p *customerAuthProcessor) CheckPhoneNumber(ctx context.Context, req *contract.CheckPhoneRequest) (*models.CheckPhoneResponse, error) {
// Check if phone number exists in database
exists, err := p.customerAuthRepo.CheckPhoneNumberExists(ctx, req.PhoneNumber)
if err != nil {
return nil, fmt.Errorf("failed to check phone number: %w", err)
}
if !exists {
// Phone number not registered
return &models.CheckPhoneResponse{
Status: "NOT_REGISTERED",
Message: "Phone number not registered. Please continue registration.",
Data: &models.CheckPhoneResponseData{
PhoneNumber: req.PhoneNumber,
},
}, nil
}
// Phone number exists, get customer details
customer, err := p.customerAuthRepo.GetCustomerByPhoneNumber(ctx, req.PhoneNumber)
if err != nil {
return nil, fmt.Errorf("failed to get customer: %w", err)
}
if customer == nil {
return nil, fmt.Errorf("customer not found")
}
// Check if customer has password set
if customer.PasswordHash == nil || *customer.PasswordHash == "" {
// Customer exists but no password set, send OTP for password setup
otpSession, err := p.otpProcessor.CreateOtpSession(ctx, req.PhoneNumber, "password_setup")
if err != nil {
return nil, fmt.Errorf("failed to create OTP session: %w", err)
}
// Send OTP via WhatsApp
if err := p.otpProcessor.SendOtpViaWhatsApp(req.PhoneNumber, otpSession.Code, "password setup"); err != nil {
return nil, fmt.Errorf("failed to send OTP: %w", err)
}
return &models.CheckPhoneResponse{
Status: "OTP_REQUIRED",
Message: "OTP sent for password setup.",
Data: &models.CheckPhoneResponseData{
OtpToken: otpSession.Token,
ExpiresIn: 300,
},
}, nil
}
// Customer exists and has password set, validate password if provided
if req.Password == "" {
return &models.CheckPhoneResponse{
Status: "PASSWORD_REQUIRED",
Message: "Password is required for login.",
}, nil
}
// Validate password
if err := bcrypt.CompareHashAndPassword([]byte(*customer.PasswordHash), []byte(req.Password)); err != nil {
return &models.CheckPhoneResponse{
Status: "INVALID_PASSWORD",
Message: "Invalid password.",
}, nil
}
// Generate JWT tokens using customer JWT util
accessToken, refreshToken, _, err := util.GenerateCustomerTokens(customer, p.jwtSecret, p.tokenTTLMinutes)
if err != nil {
return nil, fmt.Errorf("failed to generate tokens: %w", err)
}
return &models.CheckPhoneResponse{
Status: "SUCCESS",
Message: "Login successful.",
Data: &models.CheckPhoneResponseData{
AccessToken: accessToken,
RefreshToken: refreshToken,
User: &models.CustomerUserData{
ID: customer.ID,
Name: customer.Name,
PhoneNumber: *customer.PhoneNumber,
BirthDate: customer.BirthDate.Format("2006-01-02"),
},
},
}, nil
}
func (p *customerAuthProcessor) StartRegistration(ctx context.Context, req *contract.RegisterStartRequest) (*models.RegisterStartResponse, error) {
// Check if phone number already exists
exists, err := p.customerAuthRepo.CheckPhoneNumberExists(ctx, req.PhoneNumber)
if err != nil {
return nil, fmt.Errorf("failed to check phone number: %w", err)
}
if exists {
return nil, fmt.Errorf("phone number already registered")
}
// Resolve the organization before an OTP is sent, rather than failing on a foreign
// key at the last step.
organizationID, err := p.registrationOrganization(ctx, req.OrganizationID)
if err != nil {
return nil, err
}
// Generate registration token and create OTP session
registrationToken := uuid.New().String()
// Create OTP session for registration
otpSession, err := p.otpProcessor.CreateOtpSession(ctx, req.PhoneNumber, "registration")
if err != nil {
return nil, fmt.Errorf("failed to create OTP session: %w", err)
}
// Store registration data in OTP session metadata
registrationData := map[string]interface{}{
"registration_token": registrationToken,
"name": req.Name,
"birth_date": req.BirthDate,
"organization_id": organizationID.String(),
"step": "otp_sent",
}
// Update OTP session with registration metadata
otpSession.Metadata = registrationData
if err := p.otpRepo.UpdateOtpSession(ctx, otpSession); err != nil {
return nil, fmt.Errorf("failed to update OTP session with registration data: %w", err)
}
// Send OTP via WhatsApp
if err := p.otpProcessor.SendOtpViaWhatsApp(req.PhoneNumber, otpSession.Code, "registration"); err != nil {
return nil, fmt.Errorf("failed to send OTP: %w", err)
}
return &models.RegisterStartResponse{
Status: "PENDING_OTP",
Message: "OTP sent to phone number for verification.",
Data: &models.RegisterStartResponseData{
RegistrationToken: registrationToken,
OtpToken: otpSession.Token,
ExpiresIn: 300,
},
}, nil
}
func (p *customerAuthProcessor) VerifyOtp(ctx context.Context, req *contract.RegisterVerifyOtpRequest) (*models.RegisterVerifyOtpResponse, error) {
otpSession, err := p.otpRepo.GetOtpSessionByRegistrationToken(ctx, req.RegistrationToken)
if err != nil {
return nil, fmt.Errorf("failed to get OTP session: %w", err)
}
if otpSession == nil {
return nil, fmt.Errorf("invalid or expired registration token")
}
if otpSession.IsExpired() {
return nil, fmt.Errorf("registration token expired")
}
if !p.otpProcessor.ValidateOtpCode(req.OtpCode) {
return &models.RegisterVerifyOtpResponse{
Status: "FAILED",
Message: "Invalid OTP format.",
}, nil
}
if otpSession.Code != req.OtpCode {
otpSession.IncrementAttempts()
if err := p.otpRepo.UpdateOtpSession(ctx, otpSession); err != nil {
fmt.Printf("Warning: failed to update OTP session attempts: %v\n", err)
}
return &models.RegisterVerifyOtpResponse{
Status: "FAILED",
Message: "Invalid OTP code.",
}, nil
}
if otpSession.IsUsed || otpSession.IsMaxAttemptsReached() {
return &models.RegisterVerifyOtpResponse{
Status: "FAILED",
Message: "OTP code already used or max attempts reached.",
}, nil
}
// Mark OTP as used
otpSession.MarkAsUsed()
// Update registration step in metadata
if otpSession.Metadata == nil {
otpSession.Metadata = make(map[string]interface{})
}
otpSession.Metadata["step"] = "otp_verified"
if err := p.otpRepo.UpdateOtpSession(ctx, otpSession); err != nil {
return &models.RegisterVerifyOtpResponse{
Status: "FAILED",
Message: "Failed to update OTP session.",
}, nil
}
return &models.RegisterVerifyOtpResponse{
Status: "OTP_VERIFIED",
Message: "OTP verified, continue to set password.",
Data: &models.RegisterVerifyOtpResponseData{
RegistrationToken: req.RegistrationToken,
},
}, nil
}
func (p *customerAuthProcessor) SetPassword(ctx context.Context, req *contract.RegisterSetPasswordRequest) (*models.RegisterSetPasswordResponse, error) {
if req.Password != req.ConfirmPassword {
return nil, fmt.Errorf("passwords do not match")
}
// Get OTP session by registration token from metadata
otpSession, err := p.otpRepo.GetOtpSessionByRegistrationToken(ctx, req.RegistrationToken)
if err != nil {
return nil, fmt.Errorf("failed to get OTP session: %w", err)
}
if otpSession == nil {
return nil, fmt.Errorf("invalid or expired registration token")
}
if otpSession.IsExpired() {
return nil, fmt.Errorf("registration token expired")
}
step, ok := otpSession.Metadata["step"].(string)
if !ok || step != "otp_verified" {
return nil, fmt.Errorf("OTP verification required before setting password")
}
// Hash password
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %w", err)
}
passwordHashStr := string(passwordHash)
// Extract registration data from OTP session metadata
name, ok := otpSession.Metadata["name"].(string)
if !ok {
return nil, fmt.Errorf("invalid registration data: name not found")
}
birthDateStr, ok := otpSession.Metadata["birth_date"].(string)
if !ok {
return nil, fmt.Errorf("invalid registration data: birth_date not found")
}
// Parse birth date
birthDate, err := time.Parse("2006-01-02", birthDateStr)
if err != nil {
return nil, fmt.Errorf("invalid birth date format: %w", err)
}
orgIDStr, _ := otpSession.Metadata["organization_id"].(string)
organizationID, err := uuid.Parse(orgIDStr)
if err != nil {
return nil, fmt.Errorf("invalid registration data: organization not found, start the registration again")
}
customer := &entities.Customer{
OrganizationID: organizationID,
Name: name,
PhoneNumber: &otpSession.PhoneNumber,
BirthDate: &birthDate,
PasswordHash: &passwordHashStr,
IsActive: true,
}
if err := p.customerAuthRepo.CreateCustomer(ctx, customer); err != nil {
return nil, fmt.Errorf("failed to create customer: %w", err)
}
accessToken, refreshToken, _, err := util.GenerateCustomerTokens(customer, p.jwtSecret, p.tokenTTLMinutes)
if err != nil {
return nil, fmt.Errorf("failed to generate tokens: %w", err)
}
return &models.RegisterSetPasswordResponse{
Status: "REGISTERED",
Message: "Registration completed successfully.",
Data: &models.RegisterSetPasswordResponseData{
AccessToken: accessToken,
RefreshToken: refreshToken,
User: &models.CustomerUserData{
ID: customer.ID,
Name: customer.Name,
PhoneNumber: *customer.PhoneNumber,
BirthDate: birthDate.Format("2006-01-02"),
},
},
}, nil
}
func (p *customerAuthProcessor) Login(ctx context.Context, req *contract.CustomerLoginRequest) (*models.CustomerLoginResponse, error) {
// Counted before the password is checked, so attempts sent at once all count, and
// for numbers without a customer too, so a refusal never tells which numbers have
// one.
attempts, left, err := p.loginAttemptsRepo.Hit(ctx, req.PhoneNumber, customerLoginWindow)
switch {
case err != nil:
// Without the counter, logins go on unlimited rather than stop for everyone.
logger.FromContext(ctx).WithError(err).Error("CustomerAuthProcessor::Login -> failed to count the attempt")
case attempts > customerLoginMaxAttempts:
if left <= 0 {
left = customerLoginWindow
}
return nil, &CustomerLoginLockedError{Until: time.Now().Add(left).UTC().Truncate(time.Second)}
}
// Get customer by phone number
customer, err := p.customerAuthRepo.GetCustomerByPhoneNumber(ctx, req.PhoneNumber)
if err != nil {
return nil, fmt.Errorf("failed to get customer: %w", err)
}
if customer == nil {
return nil, ErrCustomerLoginInvalid
}
if customer.PasswordHash == nil {
return nil, ErrCustomerNotRegistered
}
// Verify password
if err := bcrypt.CompareHashAndPassword([]byte(*customer.PasswordHash), []byte(req.Password)); err != nil {
return nil, ErrCustomerLoginInvalid
}
if err := p.loginAttemptsRepo.Reset(ctx, req.PhoneNumber); err != nil {
logger.FromContext(ctx).WithError(err).Error("CustomerAuthProcessor::Login -> failed to reset the attempts")
}
// Generate JWT tokens using customer JWT util
accessToken, refreshToken, _, err := util.GenerateCustomerTokens(customer, p.jwtSecret, p.tokenTTLMinutes)
if err != nil {
return nil, fmt.Errorf("failed to generate tokens: %w", err)
}
return &models.CustomerLoginResponse{
Status: "SUCCESS",
Message: "Login successful.",
Data: &models.CustomerLoginResponseData{
AccessToken: accessToken,
RefreshToken: refreshToken,
User: &models.CustomerUserData{
ID: customer.ID,
Name: customer.Name,
PhoneNumber: *customer.PhoneNumber,
BirthDate: customer.BirthDate.Format("2006-01-02"),
},
},
}, nil
}
func (p *customerAuthProcessor) ResendOtp(ctx context.Context, req *contract.ResendOtpRequest) (*models.ResendOtpResponse, error) {
// Check if resend is allowed
canResend, secondsUntilNext, err := p.otpProcessor.CanResendOtp(ctx, req.PhoneNumber, req.Purpose)
if err != nil {
return nil, fmt.Errorf("failed to check resend eligibility: %w", err)
}
if !canResend {
return &models.ResendOtpResponse{
Status: "RESEND_NOT_ALLOWED",
Message: fmt.Sprintf("Please wait %d seconds before requesting a new OTP", secondsUntilNext),
Data: &models.ResendOtpResponseData{
NextResendIn: secondsUntilNext,
},
}, nil
}
// For registration purpose, check if phone number is already registered
if req.Purpose == "registration" {
exists, err := p.customerAuthRepo.CheckPhoneNumberExists(ctx, req.PhoneNumber)
if err != nil {
return nil, fmt.Errorf("failed to check phone number: %w", err)
}
if exists {
return &models.ResendOtpResponse{
Status: "PHONE_ALREADY_REGISTERED",
Message: "Phone number is already registered. Please use login instead.",
}, nil
}
}
// For login purpose, check if phone number is registered
if req.Purpose == "login" {
exists, err := p.customerAuthRepo.CheckPhoneNumberExists(ctx, req.PhoneNumber)
if err != nil {
return nil, fmt.Errorf("failed to check phone number: %w", err)
}
if !exists {
return &models.ResendOtpResponse{
Status: "PHONE_NOT_REGISTERED",
Message: "Phone number is not registered. Please register first.",
}, nil
}
}
// Resend OTP
otpSession, err := p.otpProcessor.ResendOtpSession(ctx, req.PhoneNumber, req.Purpose)
if err != nil {
return nil, fmt.Errorf("failed to resend OTP: %w", err)
}
// Calculate next resend time (60 seconds from now)
nextResendIn := 60
return &models.ResendOtpResponse{
Status: "SUCCESS",
Message: "OTP resent successfully.",
Data: &models.ResendOtpResponseData{
OtpToken: otpSession.Token,
ExpiresIn: 300, // 5 minutes
NextResendIn: nextResendIn,
},
}, nil
}
// Helper functions - OTP generation is now handled by OtpProcessor
// registrationOrganization is the organization a new customer joins: the one the app
// sent, which must exist, or, when the app sent none, the only organization there is.
// With several organizations and none sent there is no way to choose, so it refuses.
func (p *customerAuthProcessor) registrationOrganization(ctx context.Context, requested string) (uuid.UUID, error) {
requested = strings.TrimSpace(requested)
if requested != "" {
id, err := uuid.Parse(requested)
if err != nil {
return uuid.Nil, fmt.Errorf("organization_id must be a valid UUID")
}
exists, err := p.customerAuthRepo.OrganizationExists(ctx, id)
if err != nil {
return uuid.Nil, err
}
if !exists {
return uuid.Nil, fmt.Errorf("organization not found")
}
return id, nil
}
ids, err := p.customerAuthRepo.OrganizationIDs(ctx, 2)
if err != nil {
return uuid.Nil, err
}
switch len(ids) {
case 1:
return ids[0], nil
case 0:
return uuid.Nil, fmt.Errorf("no organization exists to register customers into")
default:
return uuid.Nil, fmt.Errorf("organization_id is required: there is more than one organization")
}
}