user auth register
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
"apskel-pos-be/internal/util"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
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
|
||||
otpProcessor OtpProcessor
|
||||
otpRepo repository.OtpRepository
|
||||
jwtSecret string
|
||||
tokenTTLMinutes int
|
||||
otpStorage map[string]*models.OtpSession // In-memory storage for OTP sessions
|
||||
registrationStorage map[string]*models.RegistrationSession // In-memory storage for registration sessions
|
||||
}
|
||||
|
||||
func NewCustomerAuthProcessor(customerAuthRepo repository.CustomerAuthRepository, otpProcessor OtpProcessor, otpRepo repository.OtpRepository, jwtSecret string, tokenTTLMinutes int) CustomerAuthProcessor {
|
||||
return &customerAuthProcessor{
|
||||
customerAuthRepo: customerAuthRepo,
|
||||
otpProcessor: otpProcessor,
|
||||
otpRepo: otpRepo,
|
||||
jwtSecret: jwtSecret,
|
||||
tokenTTLMinutes: tokenTTLMinutes,
|
||||
otpStorage: make(map[string]*models.OtpSession),
|
||||
registrationStorage: make(map[string]*models.RegistrationSession),
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
// 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 session
|
||||
registrationSession := &models.RegistrationSession{
|
||||
Token: registrationToken,
|
||||
PhoneNumber: req.PhoneNumber,
|
||||
Name: req.Name,
|
||||
BirthDate: req.BirthDate,
|
||||
ExpiresAt: time.Now().Add(10 * time.Minute),
|
||||
Step: "otp_sent",
|
||||
}
|
||||
|
||||
p.registrationStorage[registrationToken] = registrationSession
|
||||
|
||||
// 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) {
|
||||
// Get registration session
|
||||
registrationSession, exists := p.registrationStorage[req.RegistrationToken]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("invalid or expired registration token")
|
||||
}
|
||||
|
||||
if time.Now().After(registrationSession.ExpiresAt) {
|
||||
delete(p.registrationStorage, req.RegistrationToken)
|
||||
return nil, fmt.Errorf("registration token expired")
|
||||
}
|
||||
|
||||
// Validate OTP format
|
||||
if !p.otpProcessor.ValidateOtpCode(req.OtpCode) {
|
||||
return &models.RegisterVerifyOtpResponse{
|
||||
Status: "FAILED",
|
||||
Message: "Invalid OTP format.",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Get the OTP session for this phone number and purpose
|
||||
otpSession, err := p.otpRepo.GetOtpSessionByPhoneAndPurpose(ctx, registrationSession.PhoneNumber, "registration")
|
||||
if err != nil {
|
||||
return &models.RegisterVerifyOtpResponse{
|
||||
Status: "FAILED",
|
||||
Message: "Failed to validate OTP session.",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if otpSession == nil {
|
||||
return &models.RegisterVerifyOtpResponse{
|
||||
Status: "FAILED",
|
||||
Message: "No active OTP session found.",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Verify OTP code and mark as used
|
||||
if otpSession.Code != req.OtpCode {
|
||||
otpSession.IncrementAttempts()
|
||||
p.otpRepo.UpdateOtpSession(ctx, otpSession)
|
||||
return &models.RegisterVerifyOtpResponse{
|
||||
Status: "FAILED",
|
||||
Message: "Invalid OTP code.",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Mark OTP as used
|
||||
otpSession.MarkAsUsed()
|
||||
if err := p.otpRepo.UpdateOtpSession(ctx, otpSession); err != nil {
|
||||
return &models.RegisterVerifyOtpResponse{
|
||||
Status: "FAILED",
|
||||
Message: "Failed to update OTP session.",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Update registration session
|
||||
registrationSession.Step = "otp_verified"
|
||||
p.registrationStorage[req.RegistrationToken] = registrationSession
|
||||
|
||||
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) {
|
||||
// Validate passwords match
|
||||
if req.Password != req.ConfirmPassword {
|
||||
return nil, fmt.Errorf("passwords do not match")
|
||||
}
|
||||
|
||||
// Get registration session
|
||||
registrationSession, exists := p.registrationStorage[req.RegistrationToken]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("invalid or expired registration token")
|
||||
}
|
||||
|
||||
if time.Now().After(registrationSession.ExpiresAt) {
|
||||
delete(p.registrationStorage, req.RegistrationToken)
|
||||
return nil, fmt.Errorf("registration token expired")
|
||||
}
|
||||
|
||||
if registrationSession.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)
|
||||
|
||||
// Parse birth date
|
||||
birthDate, err := time.Parse("2006-01-02", registrationSession.BirthDate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid birth date format: %w", err)
|
||||
}
|
||||
|
||||
// Create customer
|
||||
customer := &entities.Customer{
|
||||
Name: registrationSession.Name,
|
||||
PhoneNumber: ®istrationSession.PhoneNumber,
|
||||
BirthDate: &birthDate,
|
||||
PasswordHash: &passwordHashStr,
|
||||
IsActive: true,
|
||||
// Note: OrganizationID should be set based on your business logic
|
||||
// For now, we'll use a default organization or require it in the request
|
||||
}
|
||||
|
||||
if err := p.customerAuthRepo.CreateCustomer(ctx, customer); err != nil {
|
||||
return nil, fmt.Errorf("failed to create customer: %w", err)
|
||||
}
|
||||
|
||||
// Clean up registration session
|
||||
delete(p.registrationStorage, req.RegistrationToken)
|
||||
|
||||
// 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.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: registrationSession.BirthDate,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *customerAuthProcessor) Login(ctx context.Context, req *contract.CustomerLoginRequest) (*models.CustomerLoginResponse, error) {
|
||||
// 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, fmt.Errorf("customer not found")
|
||||
}
|
||||
|
||||
if customer.PasswordHash == nil {
|
||||
return nil, fmt.Errorf("customer not properly registered")
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(*customer.PasswordHash), []byte(req.Password)); err != nil {
|
||||
return nil, fmt.Errorf("invalid password")
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -0,0 +1,238 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"apskel-pos-be/internal/client"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type OtpProcessor interface {
|
||||
GenerateOtpCode() string
|
||||
CreateOtpSession(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
|
||||
ValidateOtpCode(code string) bool
|
||||
ValidateOtpSession(ctx context.Context, token string, code string) (*entities.OtpSession, error)
|
||||
InvalidateOtpSession(ctx context.Context, token string) error
|
||||
CleanupExpiredOtps(ctx context.Context) error
|
||||
CanResendOtp(ctx context.Context, phoneNumber string, purpose string) (bool, int, error) // Returns (canResend, secondsUntilNext, error)
|
||||
}
|
||||
|
||||
type otpProcessor struct {
|
||||
fonnteClient client.FonnteClient
|
||||
otpRepo repository.OtpRepository
|
||||
}
|
||||
|
||||
func NewOtpProcessor(fonnteClient client.FonnteClient, otpRepo repository.OtpRepository) OtpProcessor {
|
||||
return &otpProcessor{
|
||||
fonnteClient: fonnteClient,
|
||||
otpRepo: otpRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *otpProcessor) GenerateOtpCode() string {
|
||||
// Generate a 6-digit OTP code
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
code := rand.Intn(900000) + 100000 // Generates number between 100000-999999
|
||||
return fmt.Sprintf("%06d", code)
|
||||
}
|
||||
|
||||
func (p *otpProcessor) CreateOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error) {
|
||||
// Generate OTP code and token
|
||||
otpCode := p.GenerateOtpCode()
|
||||
token := uuid.New().String()
|
||||
|
||||
// Invalidate any existing OTP sessions for this phone number and purpose
|
||||
if err := p.otpRepo.InvalidateOtpSessionsByPhone(ctx, phoneNumber, purpose); err != nil {
|
||||
return nil, fmt.Errorf("failed to invalidate existing OTP sessions: %w", err)
|
||||
}
|
||||
|
||||
// Create new OTP session
|
||||
otpSession := &entities.OtpSession{
|
||||
Token: token,
|
||||
Code: otpCode,
|
||||
PhoneNumber: phoneNumber,
|
||||
Purpose: purpose,
|
||||
ExpiresAt: time.Now().Add(5 * time.Minute),
|
||||
IsUsed: false,
|
||||
AttemptsCount: 0,
|
||||
MaxAttempts: 3,
|
||||
}
|
||||
|
||||
if err := p.otpRepo.CreateOtpSession(ctx, otpSession); err != nil {
|
||||
return nil, fmt.Errorf("failed to create OTP session: %w", err)
|
||||
}
|
||||
|
||||
return otpSession, nil
|
||||
}
|
||||
|
||||
func (p *otpProcessor) ResendOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error) {
|
||||
// Check if resend is allowed
|
||||
canResend, secondsUntilNext, err := p.CanResendOtp(ctx, phoneNumber, purpose)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check resend eligibility: %w", err)
|
||||
}
|
||||
|
||||
if !canResend {
|
||||
return nil, fmt.Errorf("resend not allowed yet, try again in %d seconds", secondsUntilNext)
|
||||
}
|
||||
|
||||
// Create new OTP session (this will invalidate existing ones)
|
||||
otpSession, err := p.CreateOtpSession(ctx, phoneNumber, purpose)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create resend OTP session: %w", err)
|
||||
}
|
||||
|
||||
// Send OTP via WhatsApp
|
||||
if err := p.SendOtpViaWhatsApp(phoneNumber, otpSession.Code, purpose); err != nil {
|
||||
return nil, fmt.Errorf("failed to send resend OTP: %w", err)
|
||||
}
|
||||
|
||||
return otpSession, nil
|
||||
}
|
||||
|
||||
func (p *otpProcessor) CanResendOtp(ctx context.Context, phoneNumber string, purpose string) (bool, int, error) {
|
||||
// Get the last OTP session for this phone number and purpose
|
||||
lastOtpSession, err := p.otpRepo.GetLastOtpSessionByPhoneAndPurpose(ctx, phoneNumber, purpose)
|
||||
if err != nil {
|
||||
return false, 0, fmt.Errorf("failed to get last OTP session: %w", err)
|
||||
}
|
||||
|
||||
// If no previous OTP session exists, resend is allowed immediately
|
||||
if lastOtpSession == nil {
|
||||
return true, 0, nil
|
||||
}
|
||||
|
||||
// Calculate time since last OTP was created
|
||||
timeSinceLastOtp := time.Since(lastOtpSession.CreatedAt)
|
||||
|
||||
// Minimum time between OTP sends (60 seconds)
|
||||
minResendInterval := 60 * time.Second
|
||||
|
||||
if timeSinceLastOtp < minResendInterval {
|
||||
secondsUntilNext := int((minResendInterval - timeSinceLastOtp).Seconds())
|
||||
return false, secondsUntilNext, nil
|
||||
}
|
||||
|
||||
return true, 0, nil
|
||||
}
|
||||
|
||||
func (p *otpProcessor) SendOtpViaWhatsApp(phoneNumber string, otpCode string, purpose string) error {
|
||||
// Format phone number (remove any non-digit characters and ensure it starts with country code)
|
||||
formattedPhone := p.formatPhoneNumber(phoneNumber)
|
||||
|
||||
// Create message based on purpose
|
||||
var message string
|
||||
switch purpose {
|
||||
case "login":
|
||||
message = fmt.Sprintf("Kode OTP untuk login kamu adalah %s. Berlaku 5 menit.", otpCode)
|
||||
case "registration":
|
||||
message = fmt.Sprintf("Kode OTP untuk registrasi kamu adalah %s. Berlaku 5 menit.", otpCode)
|
||||
default:
|
||||
message = fmt.Sprintf("Kode OTP kamu adalah %s. Berlaku 5 menit.", otpCode)
|
||||
}
|
||||
|
||||
// Send message via Fonnte
|
||||
if err := p.fonnteClient.SendWhatsAppMessage(formattedPhone, message); err != nil {
|
||||
fmt.Printf("Failed to send OTP via WhatsApp to %s: %v\n", formattedPhone, err)
|
||||
return fmt.Errorf("failed to send OTP via WhatsApp: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("OTP sent successfully to %s for purpose: %s\n", formattedPhone, purpose)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *otpProcessor) ValidateOtpCode(code string) bool {
|
||||
// Basic validation: should be 6 digits
|
||||
if len(code) != 6 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if all characters are digits
|
||||
for _, char := range code {
|
||||
if char < '0' || char > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *otpProcessor) ValidateOtpSession(ctx context.Context, token string, code string) (*entities.OtpSession, error) {
|
||||
// Get OTP session by token
|
||||
otpSession, err := p.otpRepo.GetOtpSessionByToken(ctx, token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get OTP session: %w", err)
|
||||
}
|
||||
|
||||
if otpSession == nil {
|
||||
return nil, fmt.Errorf("invalid OTP token")
|
||||
}
|
||||
|
||||
// Check if OTP can be used
|
||||
if !otpSession.CanBeUsed() {
|
||||
// Update attempts count if max attempts not reached
|
||||
if !otpSession.IsMaxAttemptsReached() {
|
||||
otpSession.IncrementAttempts()
|
||||
p.otpRepo.UpdateOtpSession(ctx, otpSession)
|
||||
}
|
||||
return nil, fmt.Errorf("OTP session expired, used, or max attempts reached")
|
||||
}
|
||||
|
||||
// Validate OTP code
|
||||
if otpSession.Code != code {
|
||||
otpSession.IncrementAttempts()
|
||||
if err := p.otpRepo.UpdateOtpSession(ctx, otpSession); err != nil {
|
||||
return nil, fmt.Errorf("failed to update OTP session attempts: %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("invalid OTP code")
|
||||
}
|
||||
|
||||
// Mark as used
|
||||
otpSession.MarkAsUsed()
|
||||
if err := p.otpRepo.UpdateOtpSession(ctx, otpSession); err != nil {
|
||||
return nil, fmt.Errorf("failed to mark OTP as used: %w", err)
|
||||
}
|
||||
|
||||
return otpSession, nil
|
||||
}
|
||||
|
||||
func (p *otpProcessor) InvalidateOtpSession(ctx context.Context, token string) error {
|
||||
return p.otpRepo.DeleteOtpSession(ctx, token)
|
||||
}
|
||||
|
||||
func (p *otpProcessor) CleanupExpiredOtps(ctx context.Context) error {
|
||||
return p.otpRepo.DeleteExpiredOtpSessions(ctx)
|
||||
}
|
||||
|
||||
func (p *otpProcessor) formatPhoneNumber(phoneNumber string) string {
|
||||
// Remove all non-digit characters
|
||||
digits := ""
|
||||
for _, char := range phoneNumber {
|
||||
if char >= '0' && char <= '9' {
|
||||
digits += string(char)
|
||||
}
|
||||
}
|
||||
|
||||
// If it doesn't start with country code, assume it's Indonesian (+62)
|
||||
if len(digits) == 0 {
|
||||
return phoneNumber // Return original if empty
|
||||
}
|
||||
|
||||
// If starts with 0, replace with 62
|
||||
if len(digits) > 0 && digits[0] == '0' {
|
||||
digits = "62" + digits[1:]
|
||||
} else if len(digits) > 0 && digits[:2] != "62" {
|
||||
// If doesn't start with 62, add it
|
||||
digits = "62" + digits
|
||||
}
|
||||
|
||||
return digits
|
||||
}
|
||||
Reference in New Issue
Block a user