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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
19afa50b9c
commit
a01e651709
@@ -2,12 +2,14 @@ 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"
|
||||
@@ -16,6 +18,32 @@ import (
|
||||
"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)
|
||||
@@ -26,20 +54,22 @@ type CustomerAuthProcessor interface {
|
||||
}
|
||||
|
||||
type customerAuthProcessor struct {
|
||||
customerAuthRepo repository.CustomerAuthRepository
|
||||
otpProcessor OtpProcessor
|
||||
otpRepo repository.OtpRepository
|
||||
jwtSecret string
|
||||
tokenTTLMinutes int
|
||||
customerAuthRepo repository.CustomerAuthRepository
|
||||
loginAttemptsRepo repository.CustomerLoginAttemptRepository
|
||||
otpProcessor OtpProcessor
|
||||
otpRepo repository.OtpRepository
|
||||
jwtSecret string
|
||||
tokenTTLMinutes int
|
||||
}
|
||||
|
||||
func NewCustomerAuthProcessor(customerAuthRepo repository.CustomerAuthRepository, otpProcessor OtpProcessor, otpRepo repository.OtpRepository, jwtSecret string, tokenTTLMinutes int) CustomerAuthProcessor {
|
||||
func NewCustomerAuthProcessor(customerAuthRepo repository.CustomerAuthRepository, loginAttemptsRepo repository.CustomerLoginAttemptRepository, otpProcessor OtpProcessor, otpRepo repository.OtpRepository, jwtSecret string, tokenTTLMinutes int) CustomerAuthProcessor {
|
||||
return &customerAuthProcessor{
|
||||
customerAuthRepo: customerAuthRepo,
|
||||
otpProcessor: otpProcessor,
|
||||
otpRepo: otpRepo,
|
||||
jwtSecret: jwtSecret,
|
||||
tokenTTLMinutes: tokenTTLMinutes,
|
||||
customerAuthRepo: customerAuthRepo,
|
||||
loginAttemptsRepo: loginAttemptsRepo,
|
||||
otpProcessor: otpProcessor,
|
||||
otpRepo: otpRepo,
|
||||
jwtSecret: jwtSecret,
|
||||
tokenTTLMinutes: tokenTTLMinutes,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,6 +374,21 @@ func (p *customerAuthProcessor) SetPassword(ctx context.Context, req *contract.R
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -351,16 +396,19 @@ func (p *customerAuthProcessor) Login(ctx context.Context, req *contract.Custome
|
||||
}
|
||||
|
||||
if customer == nil {
|
||||
return nil, fmt.Errorf("customer not found")
|
||||
return nil, ErrCustomerLoginInvalid
|
||||
}
|
||||
|
||||
if customer.PasswordHash == nil {
|
||||
return nil, fmt.Errorf("customer not properly registered")
|
||||
return nil, ErrCustomerNotRegistered
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(*customer.PasswordHash), []byte(req.Password)); err != nil {
|
||||
return nil, fmt.Errorf("invalid password")
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user