A customer's phone number (customers.phone_number, the one they log in with) has one stored form, 62 followed by the number without its 0: 6281234561234. util.NormalizePhoneNumber rewrites 0812…, +62 812…, 62812…, 812… and +62 0812…, with spaces, dashes, dots or parentheses, to it, and refuses anything that is not an Indonesian mobile number (628 and 7 to 11 more digits). It is applied wherever a customer types their number: check-phone, register, login and resend-OTP (the validator rewrites the request), and the transfer recipient. Before, the number was matched as typed and a leading 0 was refused, so the same customer written another way was not found. The masked recipient stays 08**-****-1234 as customers write numbers. Migration 000116 rewrites the numbers already stored in customers and otp_sessions. When several become the same number, the customer that already has it keeps it, else a registered one, else the oldest; the others, and numbers that are not Indonesian mobile numbers, are left as they are and cannot log in until fixed by hand (the query to find them is in the migration). Down does nothing. The migration was run, twice, against Postgres with a reduced customers and otp_sessions schema and sample numbers; not against the real schema. The Postgres tests were not run. customers.phone (the POS contact number) is not touched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
252 lines
7.6 KiB
Go
252 lines
7.6 KiB
Go
package validator
|
|
|
|
import (
|
|
"errors"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"apskel-pos-be/internal/constants"
|
|
"apskel-pos-be/internal/contract"
|
|
"apskel-pos-be/internal/util"
|
|
)
|
|
|
|
type CustomerAuthValidator interface {
|
|
ValidateCheckPhoneRequest(req *contract.CheckPhoneRequest) (error, string)
|
|
ValidateRegisterStartRequest(req *contract.RegisterStartRequest) (error, string)
|
|
ValidateRegisterVerifyOtpRequest(req *contract.RegisterVerifyOtpRequest) (error, string)
|
|
ValidateRegisterSetPasswordRequest(req *contract.RegisterSetPasswordRequest) (error, string)
|
|
ValidateCustomerLoginRequest(req *contract.CustomerLoginRequest) (error, string)
|
|
ValidateResendOtpRequest(req *contract.ResendOtpRequest) (error, string)
|
|
}
|
|
|
|
type CustomerAuthValidatorImpl struct{}
|
|
|
|
func NewCustomerAuthValidator() CustomerAuthValidator {
|
|
return &CustomerAuthValidatorImpl{}
|
|
}
|
|
|
|
func (v *CustomerAuthValidatorImpl) ValidateCheckPhoneRequest(req *contract.CheckPhoneRequest) (error, string) {
|
|
if req == nil {
|
|
return errors.New("request is required"), constants.ValidationErrorCode
|
|
}
|
|
|
|
// Validate phone number
|
|
if strings.TrimSpace(req.PhoneNumber) == "" {
|
|
return errors.New("phone number is required"), constants.ValidationErrorCode
|
|
}
|
|
|
|
if !v.normalizePhoneNumber(&req.PhoneNumber) {
|
|
return errors.New("invalid phone number format"), constants.ValidationErrorCode
|
|
}
|
|
|
|
return nil, ""
|
|
}
|
|
|
|
func (v *CustomerAuthValidatorImpl) ValidateRegisterStartRequest(req *contract.RegisterStartRequest) (error, string) {
|
|
if req == nil {
|
|
return errors.New("request is required"), constants.ValidationErrorCode
|
|
}
|
|
|
|
// Validate phone number
|
|
if strings.TrimSpace(req.PhoneNumber) == "" {
|
|
return errors.New("phone number is required"), constants.ValidationErrorCode
|
|
}
|
|
|
|
if !v.normalizePhoneNumber(&req.PhoneNumber) {
|
|
return errors.New("invalid phone number format"), constants.ValidationErrorCode
|
|
}
|
|
|
|
// Validate name
|
|
if strings.TrimSpace(req.Name) == "" {
|
|
return errors.New("name is required"), constants.ValidationErrorCode
|
|
}
|
|
|
|
if len(req.Name) < 2 {
|
|
return errors.New("name must be at least 2 characters long"), constants.ValidationErrorCode
|
|
}
|
|
|
|
if len(req.Name) > 100 {
|
|
return errors.New("name cannot exceed 100 characters"), constants.ValidationErrorCode
|
|
}
|
|
|
|
// Validate organization
|
|
if orgID := strings.TrimSpace(req.OrganizationID); orgID != "" {
|
|
if _, err := uuid.Parse(orgID); err != nil {
|
|
return errors.New("organization_id must be a valid UUID"), constants.ValidationErrorCode
|
|
}
|
|
}
|
|
|
|
// Validate birth date
|
|
if strings.TrimSpace(req.BirthDate) == "" {
|
|
return errors.New("birth date is required"), constants.ValidationErrorCode
|
|
}
|
|
|
|
if !v.isValidDateFormat(req.BirthDate) {
|
|
return errors.New("invalid birth date format (YYYY-MM-DD)"), constants.ValidationErrorCode
|
|
}
|
|
|
|
return nil, ""
|
|
}
|
|
|
|
func (v *CustomerAuthValidatorImpl) ValidateRegisterVerifyOtpRequest(req *contract.RegisterVerifyOtpRequest) (error, string) {
|
|
if req == nil {
|
|
return errors.New("request is required"), constants.ValidationErrorCode
|
|
}
|
|
|
|
// Validate registration token
|
|
if strings.TrimSpace(req.RegistrationToken) == "" {
|
|
return errors.New("registration token is required"), constants.ValidationErrorCode
|
|
}
|
|
|
|
// Validate OTP code
|
|
if strings.TrimSpace(req.OtpCode) == "" {
|
|
return errors.New("OTP code is required"), constants.ValidationErrorCode
|
|
}
|
|
|
|
if !v.isValidOtpCode(req.OtpCode) {
|
|
return errors.New("invalid OTP code format"), constants.ValidationErrorCode
|
|
}
|
|
|
|
return nil, ""
|
|
}
|
|
|
|
func (v *CustomerAuthValidatorImpl) ValidateRegisterSetPasswordRequest(req *contract.RegisterSetPasswordRequest) (error, string) {
|
|
if req == nil {
|
|
return errors.New("request is required"), constants.ValidationErrorCode
|
|
}
|
|
|
|
// Validate registration token
|
|
if strings.TrimSpace(req.RegistrationToken) == "" {
|
|
return errors.New("registration token is required"), constants.ValidationErrorCode
|
|
}
|
|
|
|
// Validate password
|
|
if strings.TrimSpace(req.Password) == "" {
|
|
return errors.New("password is required"), constants.ValidationErrorCode
|
|
}
|
|
|
|
if len(req.Password) < 8 {
|
|
return errors.New("password must be at least 8 characters long"), constants.ValidationErrorCode
|
|
}
|
|
|
|
if len(req.Password) > 128 {
|
|
return errors.New("password cannot exceed 128 characters"), constants.ValidationErrorCode
|
|
}
|
|
|
|
// Validate confirm password
|
|
if strings.TrimSpace(req.ConfirmPassword) == "" {
|
|
return errors.New("confirm password is required"), constants.ValidationErrorCode
|
|
}
|
|
|
|
if req.Password != req.ConfirmPassword {
|
|
return errors.New("passwords do not match"), constants.ValidationErrorCode
|
|
}
|
|
|
|
// Validate password strength
|
|
if !v.isStrongPassword(req.Password) {
|
|
return errors.New("password must contain at least one uppercase letter, one lowercase letter, and one number"), constants.ValidationErrorCode
|
|
}
|
|
|
|
return nil, ""
|
|
}
|
|
|
|
func (v *CustomerAuthValidatorImpl) ValidateCustomerLoginRequest(req *contract.CustomerLoginRequest) (error, string) {
|
|
if req == nil {
|
|
return errors.New("request is required"), constants.ValidationErrorCode
|
|
}
|
|
|
|
// Validate phone number
|
|
if strings.TrimSpace(req.PhoneNumber) == "" {
|
|
return errors.New("phone number is required"), constants.ValidationErrorCode
|
|
}
|
|
|
|
if !v.normalizePhoneNumber(&req.PhoneNumber) {
|
|
return errors.New("invalid phone number format"), constants.ValidationErrorCode
|
|
}
|
|
|
|
// Validate password
|
|
if strings.TrimSpace(req.Password) == "" {
|
|
return errors.New("password is required"), constants.ValidationErrorCode
|
|
}
|
|
|
|
return nil, ""
|
|
}
|
|
|
|
// Helper validation functions
|
|
// normalizePhoneNumber rewrites the phone number in the form it is stored in, 62…
|
|
// (util.NormalizePhoneNumber), and reports false when it is not a valid one.
|
|
func (v *CustomerAuthValidatorImpl) normalizePhoneNumber(phoneNumber *string) bool {
|
|
normalized, err := util.NormalizePhoneNumber(*phoneNumber)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
*phoneNumber = normalized
|
|
return true
|
|
}
|
|
|
|
func (v *CustomerAuthValidatorImpl) isValidDateFormat(date string) bool {
|
|
// Basic date format validation for YYYY-MM-DD
|
|
dateRegex := regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
|
if !dateRegex.MatchString(date) {
|
|
return false
|
|
}
|
|
|
|
// You can add more sophisticated date validation here if needed
|
|
return true
|
|
}
|
|
|
|
func (v *CustomerAuthValidatorImpl) isValidOtpCode(code string) bool {
|
|
// OTP code should be 4-8 digits
|
|
otpRegex := regexp.MustCompile(`^\d{4,8}$`)
|
|
return otpRegex.MatchString(code)
|
|
}
|
|
|
|
func (v *CustomerAuthValidatorImpl) ValidateResendOtpRequest(req *contract.ResendOtpRequest) (error, string) {
|
|
if req == nil {
|
|
return errors.New("request is required"), constants.CustomerEntity
|
|
}
|
|
|
|
// Validate phone number
|
|
if req.PhoneNumber == "" {
|
|
return errors.New("phone number is required"), constants.CustomerEntity
|
|
}
|
|
|
|
// Validate phone number format
|
|
if !v.normalizePhoneNumber(&req.PhoneNumber) {
|
|
return errors.New("invalid phone number format"), constants.CustomerEntity
|
|
}
|
|
|
|
// Validate purpose
|
|
if req.Purpose == "" {
|
|
return errors.New("purpose is required"), constants.CustomerEntity
|
|
}
|
|
|
|
// Validate purpose values
|
|
validPurposes := []string{"login", "registration"}
|
|
if !v.contains(validPurposes, req.Purpose) {
|
|
return errors.New("purpose must be either 'login' or 'registration'"), constants.CustomerEntity
|
|
}
|
|
|
|
return nil, ""
|
|
}
|
|
|
|
func (v *CustomerAuthValidatorImpl) isStrongPassword(password string) bool {
|
|
// Password must contain at least one uppercase, one lowercase, and one number
|
|
hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password)
|
|
hasLower := regexp.MustCompile(`[a-z]`).MatchString(password)
|
|
hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password)
|
|
|
|
return hasUpper && hasLower && hasNumber
|
|
}
|
|
|
|
func (v *CustomerAuthValidatorImpl) contains(slice []string, item string) bool {
|
|
for _, s := range slice {
|
|
if s == item {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|