Files
apskel-pos-backend/internal/handler/customer_auth_handler.go
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

223 lines
12 KiB
Go

package handler
import (
"errors"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/util"
"apskel-pos-be/internal/validator"
"github.com/gin-gonic/gin"
)
type CustomerAuthHandler struct {
customerAuthService service.CustomerAuthService
customerAuthValidator validator.CustomerAuthValidator
}
func NewCustomerAuthHandler(customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator) *CustomerAuthHandler {
return &CustomerAuthHandler{
customerAuthService: customerAuthService,
customerAuthValidator: customerAuthValidator,
}
}
func (h *CustomerAuthHandler) CheckPhone(c *gin.Context) {
ctx := c.Request.Context()
var req contract.CheckPhoneRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("CustomerAuthHandler::CheckPhone -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CustomerAuthHandler::CheckPhone")
return
}
validationError, validationErrorCode := h.customerAuthValidator.ValidateCheckPhoneRequest(&req)
if validationError != nil {
logger.FromContext(c.Request.Context()).WithError(validationError).Error("CustomerAuthHandler::CheckPhone -> request validation failed")
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CustomerAuthHandler::CheckPhone")
return
}
response, err := h.customerAuthService.CheckPhoneNumber(ctx, &req)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("CustomerAuthHandler::CheckPhone -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error())}), "CustomerAuthHandler::CheckPhone")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerAuthHandler::CheckPhone")
}
func (h *CustomerAuthHandler) RegisterStart(c *gin.Context) {
ctx := c.Request.Context()
var req contract.RegisterStartRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("CustomerAuthHandler::RegisterStart -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CustomerAuthHandler::RegisterStart")
return
}
validationError, validationErrorCode := h.customerAuthValidator.ValidateRegisterStartRequest(&req)
if validationError != nil {
logger.FromContext(c.Request.Context()).WithError(validationError).Error("CustomerAuthHandler::RegisterStart -> request validation failed")
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CustomerAuthHandler::RegisterStart")
return
}
response, err := h.customerAuthService.StartRegistration(ctx, &req)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("CustomerAuthHandler::RegisterStart -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error())}), "CustomerAuthHandler::RegisterStart")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerAuthHandler::RegisterStart")
}
func (h *CustomerAuthHandler) RegisterVerifyOtp(c *gin.Context) {
ctx := c.Request.Context()
var req contract.RegisterVerifyOtpRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("CustomerAuthHandler::RegisterVerifyOtp -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CustomerAuthHandler::RegisterVerifyOtp")
return
}
validationError, validationErrorCode := h.customerAuthValidator.ValidateRegisterVerifyOtpRequest(&req)
if validationError != nil {
logger.FromContext(c.Request.Context()).WithError(validationError).Error("CustomerAuthHandler::RegisterVerifyOtp -> request validation failed")
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CustomerAuthHandler::RegisterVerifyOtp")
return
}
response, err := h.customerAuthService.VerifyOtp(ctx, &req)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("CustomerAuthHandler::RegisterVerifyOtp -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error())}), "CustomerAuthHandler::RegisterVerifyOtp")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerAuthHandler::RegisterVerifyOtp")
}
func (h *CustomerAuthHandler) RegisterSetPassword(c *gin.Context) {
ctx := c.Request.Context()
var req contract.RegisterSetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("CustomerAuthHandler::RegisterSetPassword -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CustomerAuthHandler::RegisterSetPassword")
return
}
validationError, validationErrorCode := h.customerAuthValidator.ValidateRegisterSetPasswordRequest(&req)
if validationError != nil {
logger.FromContext(c.Request.Context()).WithError(validationError).Error("CustomerAuthHandler::RegisterSetPassword -> request validation failed")
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CustomerAuthHandler::RegisterSetPassword")
return
}
response, err := h.customerAuthService.SetPassword(ctx, &req)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("CustomerAuthHandler::RegisterSetPassword -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error())}), "CustomerAuthHandler::RegisterSetPassword")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerAuthHandler::RegisterSetPassword")
}
func (h *CustomerAuthHandler) Login(c *gin.Context) {
ctx := c.Request.Context()
var req contract.CustomerLoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("CustomerAuthHandler::Login -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CustomerAuthHandler::Login")
return
}
validationError, validationErrorCode := h.customerAuthValidator.ValidateCustomerLoginRequest(&req)
if validationError != nil {
logger.FromContext(c.Request.Context()).WithError(validationError).Error("CustomerAuthHandler::Login -> request validation failed")
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CustomerAuthHandler::Login")
return
}
response, err := h.customerAuthService.Login(ctx, &req)
if err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("CustomerAuthHandler::Login -> service call failed")
util.HandleResponse(c.Writer, c.Request, loginErrorResponse(err), "CustomerAuthHandler::Login")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerAuthHandler::Login")
}
// loginErrorResponse answers a phone number with too many attempts with 429 and when it
// may try again, a refused login with 304 and its reason, and anything else with 900.
func loginErrorResponse(err error) *contract.Response {
var locked *processor.CustomerLoginLockedError
if errors.As(err, &locked) {
return &contract.Response{
Success: false,
Data: map[string]interface{}{"locked_until": locked.Until},
Errors: []*contract.ResponseError{contract.NewResponseError(constants.TooManyRequestsErrorCode, constants.CustomerAuthServiceEntity, locked.Error())},
}
}
for _, refused := range []error{processor.ErrCustomerLoginInvalid, processor.ErrCustomerNotRegistered} {
if errors.Is(err, refused) {
return contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.CustomerAuthServiceEntity, refused.Error()),
})
}
}
return contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()),
})
}
func (h *CustomerAuthHandler) ResendOtp(c *gin.Context) {
ctx := c.Request.Context()
var req contract.ResendOtpRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("CustomerAuthHandler::ResendOtp -> binding request failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())}), "CustomerAuthHandler::ResendOtp")
return
}
// Validate request
if err, entity := h.customerAuthValidator.ValidateResendOtpRequest(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("CustomerAuthHandler::ResendOtp -> validation failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.MissingFieldErrorCode, entity, err.Error())}), "CustomerAuthHandler::ResendOtp")
return
}
response, err := h.customerAuthService.ResendOtp(ctx, &req)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("CustomerAuthHandler::ResendOtp -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error())}), "CustomerAuthHandler::ResendOtp")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerAuthHandler::ResendOtp")
}