forgot password - sms and email

This commit is contained in:
Asher Samuel 2025-06-10 09:56:54 +03:00
parent 6dbce0725d
commit 4c865d4d91
11 changed files with 148 additions and 60 deletions

View File

@ -18,8 +18,7 @@ import (
// mongologger "github.com/SamuelTariku/FortuneBet-Backend/internal/logger/mongoLogger" // mongologger "github.com/SamuelTariku/FortuneBet-Backend/internal/logger/mongoLogger"
// "github.com/SamuelTariku/FortuneBet-Backend/internal/logger/mongoLogger" // "github.com/SamuelTariku/FortuneBet-Backend/internal/logger/mongoLogger"
mockemail "github.com/SamuelTariku/FortuneBet-Backend/internal/mocks/mock_email"
mocksms "github.com/SamuelTariku/FortuneBet-Backend/internal/mocks/mock_sms"
"github.com/SamuelTariku/FortuneBet-Backend/internal/repository" "github.com/SamuelTariku/FortuneBet-Backend/internal/repository"
// "github.com/SamuelTariku/FortuneBet-Backend/internal/router" // "github.com/SamuelTariku/FortuneBet-Backend/internal/router"
@ -109,10 +108,8 @@ func main() {
v := customvalidator.NewCustomValidator(validator.New()) v := customvalidator.NewCustomValidator(validator.New())
authSvc := authentication.NewService(store, store, cfg.RefreshExpiry) authSvc := authentication.NewService(store, store, cfg.RefreshExpiry)
mockSms := mocksms.NewMockSMS()
mockEmail := mockemail.NewMockEmail()
userSvc := user.NewService(store, store, mockSms, mockEmail) userSvc := user.NewService(store, store, cfg)
eventSvc := event.New(cfg.Bet365Token, store) eventSvc := event.New(cfg.Bet365Token, store)
oddsSvc := odds.New(store, cfg, logger) oddsSvc := odds.New(store, cfg, logger)

View File

@ -37,7 +37,7 @@ CREATE TABLE IF NOT EXISTS wallet_threshold_notifications (
PRIMARY KEY (company_id, threshold) PRIMARY KEY (company_id, threshold)
); );
CREATE INDEX idx_wallet_threshold_notifications_company ON wallet_threshold_notifications(company_id) CREATE INDEX idx_wallet_threshold_notifications_company ON wallet_threshold_notifications(company_id);
CREATE INDEX idx_notifications_recipient_id ON notifications (recipient_id); CREATE INDEX idx_notifications_recipient_id ON notifications (recipient_id);

5
go.mod
View File

@ -76,4 +76,7 @@ require (
go.uber.org/zap v1.27.0 go.uber.org/zap v1.27.0
) )
require go.uber.org/multierr v1.10.0 // indirect require (
github.com/resend/resend-go/v2 v2.20.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
)

2
go.sum
View File

@ -110,6 +110,8 @@ github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT9
github.com/otiai10/mint v1.3.3/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= github.com/otiai10/mint v1.3.3/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/resend/resend-go/v2 v2.20.0 h1:MrIrgV0aHhwRgmcRPw33Nexn6aGJvCvG2XwfFpAMBGM=
github.com/resend/resend-go/v2 v2.20.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=

View File

@ -13,23 +13,25 @@ import (
) )
var ( var (
ErrInvalidDbUrl = errors.New("db url is invalid") ErrInvalidDbUrl = errors.New("db url is invalid")
ErrInvalidPort = errors.New("port number is invalid") ErrInvalidPort = errors.New("port number is invalid")
ErrRefreshExpiry = errors.New("refresh token expiry is invalid") ErrRefreshExpiry = errors.New("refresh token expiry is invalid")
ErrAccessExpiry = errors.New("access token expiry is invalid") ErrAccessExpiry = errors.New("access token expiry is invalid")
ErrInvalidJwtKey = errors.New("jwt key is invalid") ErrInvalidJwtKey = errors.New("jwt key is invalid")
ErrLogLevel = errors.New("log level not set") ErrLogLevel = errors.New("log level not set")
ErrInvalidLevel = errors.New("invalid log level") ErrInvalidLevel = errors.New("invalid log level")
ErrInvalidEnv = errors.New("env not set or invalid") ErrInvalidEnv = errors.New("env not set or invalid")
ErrInvalidSMSAPIKey = errors.New("SMS API key is invalid") ErrInvalidSMSAPIKey = errors.New("SMS API key is invalid")
ErrMissingBetToken = errors.New("missing BET365_TOKEN in .env") ErrMissingBetToken = errors.New("missing BET365_TOKEN in .env")
ErrInvalidPopOKClientID = errors.New("PopOK client ID is invalid") ErrInvalidPopOKClientID = errors.New("PopOK client ID is invalid")
ErrInvalidPopOKSecretKey = errors.New("PopOK secret key is invalid") ErrInvalidPopOKSecretKey = errors.New("PopOK secret key is invalid")
ErrInvalidPopOKBaseURL = errors.New("PopOK base URL is invalid") ErrInvalidPopOKBaseURL = errors.New("PopOK base URL is invalid")
ErrInvalidPopOKCallbackURL = errors.New("PopOK callback URL is invalid") ErrInvalidPopOKCallbackURL = errors.New("PopOK callback URL is invalid")
ErrInvalidVeliAPIURL = errors.New("Veli API URL is invalid") ErrInvalidVeliAPIURL = errors.New("Veli API URL is invalid")
ErrInvalidVeliOperatorKey = errors.New("Veli operator key is invalid") ErrInvalidVeliOperatorKey = errors.New("Veli operator key is invalid")
ErrInvalidVeliSecretKey = errors.New("Veli secret key is invalid") ErrInvalidVeliSecretKey = errors.New("Veli secret key is invalid")
ErrMissingResendApiKey = errors.New("missing Resend Api key")
ErrMissingResendSenderEmail = errors.New("missing Resend sender name")
) )
type AleaPlayConfig struct { type AleaPlayConfig struct {
@ -75,6 +77,8 @@ type Config struct {
PopOK domain.PopOKConfig PopOK domain.PopOKConfig
AleaPlay AleaPlayConfig `mapstructure:"alea_play"` AleaPlay AleaPlayConfig `mapstructure:"alea_play"`
VeliGames VeliGamesConfig `mapstructure:"veli_games"` VeliGames VeliGamesConfig `mapstructure:"veli_games"`
ResendApiKey string
ResendSenderEmail string
} }
func NewConfig() (*Config, error) { func NewConfig() (*Config, error) {
@ -287,6 +291,19 @@ func (c *Config) loadEnv() error {
return ErrMissingBetToken return ErrMissingBetToken
} }
c.Bet365Token = betToken c.Bet365Token = betToken
resendApiKey := os.Getenv("RESEND_API_KEY")
if resendApiKey == "" {
return ErrMissingResendApiKey
}
c.ResendApiKey = resendApiKey
resendSenderEmail := os.Getenv("RESEND_SENDER_EMAIL")
if resendSenderEmail == "" {
return ErrMissingResendSenderEmail
}
c.ResendSenderEmail = resendSenderEmail
return nil return nil
} }

View File

@ -1,7 +1,17 @@
package helpers package helpers
import "github.com/google/uuid" import (
"fmt"
"math/rand/v2"
"github.com/google/uuid"
)
func GenerateID() string { func GenerateID() string {
return uuid.New().String() return uuid.New().String()
} }
func GenerateOTP() string {
num := 100000 + rand.UintN(899999)
return fmt.Sprintf("%d", num) // 6 digit random number [100,000 - 999,999]
}

View File

@ -40,7 +40,7 @@ func (s *Service) Login(ctx context.Context, email, phone string, password strin
} }
oldRefreshToken, err := s.tokenStore.GetRefreshTokenByUserID(ctx, user.ID) oldRefreshToken, err := s.tokenStore.GetRefreshTokenByUserID(ctx, user.ID)
if err != nil && err != ErrRefreshTokenNotFound { if err != nil && err != ErrRefreshTokenNotFound {
return LoginSuccess{}, err return LoginSuccess{}, err
} }
@ -48,7 +48,7 @@ func (s *Service) Login(ctx context.Context, email, phone string, password strin
// If old refresh token is not revoked, revoke it // If old refresh token is not revoked, revoke it
if err == nil && !oldRefreshToken.Revoked { if err == nil && !oldRefreshToken.Revoked {
err = s.tokenStore.RevokeRefreshToken(ctx, oldRefreshToken.Token) err = s.tokenStore.RevokeRefreshToken(ctx, oldRefreshToken.Token)
if(err != nil) { if err != nil {
return LoginSuccess{}, err return LoginSuccess{}, err
} }
} }

View File

@ -2,14 +2,32 @@ package user
import ( import (
"context" "context"
"errors"
"fmt"
"time" "time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/SamuelTariku/FortuneBet-Backend/internal/pkgs/helpers"
afro "github.com/amanuelabay/afrosms-go"
"github.com/resend/resend-go/v2"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
func (s *Service) SendOtp(ctx context.Context, sentTo string, otpFor domain.OtpFor, medium domain.OtpMedium) error { func (s *Service) SendOtp(ctx context.Context, sentTo string, otpFor domain.OtpFor, medium domain.OtpMedium) error {
otpCode := "123456" // Generate OTP code otpCode := helpers.GenerateOTP()
message := fmt.Sprintf("Welcome to Fortune bets, your OTP is %s please don't share with anyone.", otpCode)
switch medium {
case domain.OtpMediumSms:
if err := s.SendSMSOTP(ctx, sentTo, message); err != nil {
return err
}
case domain.OtpMediumEmail:
if err := s.SendEmailOTP(ctx, sentTo, message); err != nil {
return err
}
}
otp := domain.Otp{ otp := domain.Otp{
SentTo: sentTo, SentTo: sentTo,
@ -21,19 +39,9 @@ func (s *Service) SendOtp(ctx context.Context, sentTo string, otpFor domain.OtpF
ExpiresAt: time.Now().Add(OtpExpiry), ExpiresAt: time.Now().Add(OtpExpiry),
} }
err := s.otpStore.CreateOtp(ctx, otp) return s.otpStore.CreateOtp(ctx, otp)
if err != nil {
return err
}
switch medium {
case domain.OtpMediumSms:
return s.smsGateway.SendSMSOTP(ctx, sentTo, otpCode)
case domain.OtpMediumEmail:
return s.emailGateway.SendEmailOTP(ctx, sentTo, otpCode)
}
return nil
} }
func hashPassword(plaintextPassword string) ([]byte, error) { func hashPassword(plaintextPassword string) ([]byte, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), 12) hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), 12)
if err != nil { if err != nil {
@ -42,3 +50,50 @@ func hashPassword(plaintextPassword string) ([]byte, error) {
return hash, nil return hash, nil
} }
func (s *Service) SendSMSOTP(ctx context.Context, receiverPhone, message string) error {
apiKey := s.config.AFRO_SMS_API_KEY
senderName := s.config.AFRO_SMS_SENDER_NAME
hostURL := s.config.ADRO_SMS_HOST_URL
endpoint := "/api/send"
// API endpoint has been updated
// TODO: no need for package for the afro message operations (pretty simple stuff)
request := afro.GetRequest(apiKey, endpoint, hostURL)
request.BaseURL = "https://api.afromessage.com/api/send"
request.Method = "GET"
request.Sender(senderName)
request.To(receiverPhone, message)
response, err := afro.MakeRequestWithContext(ctx, request)
if err != nil {
return err
}
if response["acknowledge"] == "success" {
return nil
} else {
fmt.Println(response["response"].(map[string]interface{}))
return errors.New("SMS delivery failed")
}
}
func (s *Service) SendEmailOTP(ctx context.Context, receiverEmail, message string) error {
apiKey := s.config.ResendApiKey
client := resend.NewClient(apiKey)
formattedSenderEmail := "FortuneBets <" + s.config.ResendSenderEmail + ">"
params := &resend.SendEmailRequest{
From: formattedSenderEmail,
To: []string{receiverEmail},
Subject: "FortuneBets - One Time Password",
Text: message,
}
_, err := client.Emails.Send(params)
if err != nil {
return err
}
return nil
}

View File

@ -2,6 +2,8 @@ package user
import ( import (
"time" "time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/config"
) )
const ( const (
@ -9,21 +11,19 @@ const (
) )
type Service struct { type Service struct {
userStore UserStore userStore UserStore
otpStore OtpStore otpStore OtpStore
smsGateway SmsGateway config *config.Config
emailGateway EmailGateway
} }
func NewService( func NewService(
userStore UserStore, userStore UserStore,
otpStore OtpStore, smsGateway SmsGateway, otpStore OtpStore,
emailGateway EmailGateway, cfg *config.Config,
) *Service { ) *Service {
return &Service{ return &Service{
userStore: userStore, userStore: userStore,
otpStore: otpStore, otpStore: otpStore,
smsGateway: smsGateway, config: cfg,
emailGateway: emailGateway,
} }
} }

View File

@ -8,19 +8,21 @@ import (
"github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
// loginCustomerReq represents the request body for the LoginCustomer endpoint. // loginCustomerReq represents the request body for the LoginCustomer endpoint.
type loginCustomerReq struct { type loginCustomerReq struct {
Email string `json:"email" validate:"email" example:"john.doe@example.com"` Email string `json:"email" validate:"email" example:"john.doe@example.com"`
PhoneNumber string `json:"phone_number" validate:"required_without=Email" example:"1234567890"` PhoneNumber string `json:"phone_number" validate:"required_without=Email" example:"1234567890"`
Password string `json:"password" validate:"required" example:"password123"` Password string `json:"password" validate:"required" example:"password123"`
} }
// loginCustomerRes represents the response body for the LoginCustomer endpoint. // loginCustomerRes represents the response body for the LoginCustomer endpoint.
type loginCustomerRes struct { type loginCustomerRes struct {
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"` RefreshToken string `json:"refresh_token"`
Role string `json:"role"` Role string `json:"role"`
} }
// LoginCustomer godoc // LoginCustomer godoc
// @Summary Login customer // @Summary Login customer
// @Description Login customer // @Description Login customer
@ -74,8 +76,8 @@ func (h *Handler) LoginCustomer(c *fiber.Ctx) error {
} }
type refreshToken struct { type refreshToken struct {
AccessToken string `json:"access_token" validate:"required" example:"<jwt-token>"` AccessToken string `json:"access_token" validate:"required" example:"<jwt-token>"`
RefreshToken string `json:"refresh_token" validate:"required" example:"<refresh-token>"` RefreshToken string `json:"refresh_token" validate:"required" example:"<refresh-token>"`
} }
// RefreshToken godoc // RefreshToken godoc

View File

@ -2,6 +2,7 @@ package handlers
import ( import (
"errors" "errors"
"fmt"
"strconv" "strconv"
"time" "time"
@ -243,6 +244,7 @@ func (h *Handler) SendResetCode(c *fiber.Ctx) error {
if err := h.userSvc.SendResetCode(c.Context(), medium, sentTo); err != nil { if err := h.userSvc.SendResetCode(c.Context(), medium, sentTo); err != nil {
h.logger.Error("Failed to send reset code", "error", err) h.logger.Error("Failed to send reset code", "error", err)
fmt.Println(err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to send reset code") return fiber.NewError(fiber.StatusInternalServerError, "Failed to send reset code")
} }
@ -250,8 +252,8 @@ func (h *Handler) SendResetCode(c *fiber.Ctx) error {
} }
type ResetPasswordReq struct { type ResetPasswordReq struct {
Email string `json:"email" validate:"email" example:"john.doe@example.com"` Email string `json:"email,omitempty" validate:"required_without=PhoneNumber,omitempty,email" example:"john.doe@example.com"`
PhoneNumber string `json:"phone_number" validate:"required_without=Email" example:"1234567890"` PhoneNumber string `json:"phone_number,omitempty" validate:"required_without=Email,omitempty" example:"1234567890"`
Password string `json:"password" validate:"required,min=8" example:"newpassword123"` Password string `json:"password" validate:"required,min=8" example:"newpassword123"`
Otp string `json:"otp" validate:"required" example:"123456"` Otp string `json:"otp" validate:"required" example:"123456"`
} }