From 5829b471d5107a275fdb081945f29df0573f57ba Mon Sep 17 00:00:00 2001 From: AmirMahdi Qiasvand Date: Mon, 15 Sep 2025 16:36:52 +0330 Subject: [PATCH] feat: kavenegar sdk, otp generation and verifying --- config/config.go | 18 +++++++---- go.mod | 1 + go.sum | 2 ++ internal/api/dto/auth.go | 4 +++ internal/app/app.go | 5 +++ internal/domain/otp.go | 7 ++++- internal/repository/cache/otp_repo.go | 28 ++++++++++++++++- internal/usecase/auth_service.go | 44 +++++++++++++++++++++++++-- pkg/sms/kavengar.go | 32 +++++++++++++++++++ 9 files changed, 131 insertions(+), 10 deletions(-) create mode 100644 pkg/sms/kavengar.go diff --git a/config/config.go b/config/config.go index 0373932..e470a0d 100644 --- a/config/config.go +++ b/config/config.go @@ -1,12 +1,13 @@ package config type Config struct { - Server Server `mapstructure:"server"` - JWT JWT `mapstructure:"jwt"` - DB DB `mapstructure:"db"` - Redis Redis `mapstructure:"redis"` - KYCProvider KYC `mapstructure:"kyc"` - OTP OTP `mapstructure:"otp"` + Server Server `mapstructure:"server"` + JWT JWT `mapstructure:"jwt"` + DB DB `mapstructure:"db"` + Redis Redis `mapstructure:"redis"` + KYCProvider KYC `mapstructure:"kyc"` + OTP OTP `mapstructure:"otp"` + Kavenegar SMSProvider `mapstructure:"kavenegar"` } type Server struct { Host string `mapstructure:"host"` @@ -41,3 +42,8 @@ type KYC struct { type OTP struct { CodeExpMinutes uint `mapstructure:"code_exp_minutes"` } + +type SMSProvider struct { + APIKey string `mapstructure:"api_key"` + Template string `mapstructure:"template"` +} diff --git a/go.mod b/go.mod index d45fa8e..fb81e10 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/gofiber/fiber/v2 v2.52.9 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 + github.com/kavenegar/kavenegar-go v0.0.0-20240205151018-77039f51467d github.com/redis/go-redis/v9 v9.13.0 github.com/spf13/viper v1.21.0 github.com/swaggo/http-swagger v1.3.4 diff --git a/go.sum b/go.sum index 150fdf4..9b7730b 100644 --- a/go.sum +++ b/go.sum @@ -149,6 +149,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kavenegar/kavenegar-go v0.0.0-20240205151018-77039f51467d h1:5yPyBSS28Nojbr7pAkiXADGj6VpTXx73o6SsprKbSoo= +github.com/kavenegar/kavenegar-go v0.0.0-20240205151018-77039f51467d/go.mod h1:CRhvvr4KNAyrg+ewrutOf+/QoHs7lztSoLjp+GqhYlA= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= diff --git a/internal/api/dto/auth.go b/internal/api/dto/auth.go index 2d08e7f..145cb6a 100644 --- a/internal/api/dto/auth.go +++ b/internal/api/dto/auth.go @@ -24,3 +24,7 @@ type AuthenticateResponse struct { type RefreshTokenRequest struct { RefreshToken string `json:"refreshToken" validate:"required,jwt"` } + +type OTPProviderReq struct { + Receptor string `json:"receptor"` +} diff --git a/internal/app/app.go b/internal/app/app.go index 5922deb..ad853c1 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -8,6 +8,7 @@ import ( "backend/internal/usecase" cachePackage "backend/pkg/cache" "backend/pkg/postgres" + "backend/pkg/sms" "backend/pkg/zohal" "fmt" "log" @@ -45,13 +46,17 @@ func NewAppContainer(cfg config.Config) (*AppContainer, error) { sessionRepo := cache.NewSessionRepository(redis) challengeRepo := cache.NewChallengeRepository(redis) zohal := zohal.NewZohal(cfg.KYCProvider) + otpRepo := cache.NewOTPRepository(redis) + smsProvider := sms.NewKavenegar(cfg.Kavenegar) authService := usecase.NewAuthService( userRepo, sessionRepo, challengeRepo, 30, + otpRepo, zohal, + smsProvider, cfg, ) diff --git a/internal/domain/otp.go b/internal/domain/otp.go index ef1d66e..4fea776 100644 --- a/internal/domain/otp.go +++ b/internal/domain/otp.go @@ -10,6 +10,7 @@ import ( type OTPRepo interface { Create(ctx context.Context, phone string, otp *OTP) error + Get(ctx context.Context, phone string) (*OTP, error) Delete(ctx context.Context, phone string) error } @@ -17,7 +18,7 @@ type OTP struct { ID uuid.UUID Code string Phone string - ExpiredAt time.Time + ExpiresAt time.Time } func NewOTP(phone string) *OTP { @@ -27,3 +28,7 @@ func NewOTP(phone string) *OTP { Phone: phone, } } + +func (o *OTP) IsExpired() bool { + return time.Now().After(o.ExpiresAt) +} diff --git a/internal/repository/cache/otp_repo.go b/internal/repository/cache/otp_repo.go index c6089c7..35435aa 100644 --- a/internal/repository/cache/otp_repo.go +++ b/internal/repository/cache/otp_repo.go @@ -7,6 +7,8 @@ import ( "encoding/json" "fmt" "time" + + "github.com/redis/go-redis/v9" ) type OTPRepository struct { @@ -27,7 +29,7 @@ func (r *OTPRepository) Create(ctx context.Context, phone string, otp *domain.OT return fmt.Errorf("failed to marshal otp: %w", err) } - ttl := time.Until(otp.ExpiredAt) + ttl := time.Until(otp.ExpiresAt) if ttl <= 0 { return fmt.Errorf("OTP expired already") } @@ -48,6 +50,30 @@ func (r *OTPRepository) Delete(ctx context.Context, phone string) error { return nil } +func (r *OTPRepository) Get(ctx context.Context, phone string) (*domain.OTP, error) { + key := r.getOTPKey(phone) + + result, err := r.redis.Client().Get(ctx, key).Result() + if err != nil { + if err == redis.Nil { + return nil, fmt.Errorf("otp code not found") + } + return nil, err + } + + var otp domain.OTP + if err := json.Unmarshal([]byte(result), &otp); err != nil { + return nil, err + } + + if otp.IsExpired() { + r.Delete(ctx, phone) + return nil, fmt.Errorf("otp is expired") + } + return &otp, nil + +} + func (r *OTPRepository) getOTPKey(phone string) string { return fmt.Sprintf("challenge:%s", phone) } diff --git a/internal/usecase/auth_service.go b/internal/usecase/auth_service.go index 75f271a..3d1fb7f 100644 --- a/internal/usecase/auth_service.go +++ b/internal/usecase/auth_service.go @@ -4,6 +4,7 @@ import ( "backend/config" "backend/internal/domain" "backend/pkg/jwt" + "backend/pkg/sms" "backend/pkg/validate/common" "backend/pkg/zohal" "context" @@ -12,6 +13,7 @@ import ( "time" jwt2 "github.com/golang-jwt/jwt/v5" + "github.com/kavenegar/kavenegar-go" "github.com/google/uuid" ) @@ -25,7 +27,9 @@ type authService struct { userRepo domain.UserRepo sessionRepo domain.SessionRepo challengeRepo domain.ChallengeRepo + otpRepo domain.OTPRepo zohal *zohal.Zohal + smsProvider *sms.Kavenegar challengeExp uint cfg config.Config } @@ -41,15 +45,19 @@ func NewAuthService( sessionRepo domain.SessionRepo, challengeRepo domain.ChallengeRepo, challengeExp uint, + otpRepo domain.OTPRepo, zohal *zohal.Zohal, + smsProvider *sms.Kavenegar, cfg config.Config, ) AuthService { return &authService{ userRepo: userRepo, sessionRepo: sessionRepo, challengeRepo: challengeRepo, + otpRepo: otpRepo, challengeExp: challengeExp, zohal: zohal, + smsProvider: smsProvider, cfg: cfg, } } @@ -190,6 +198,38 @@ func (s *authService) VerifyKYC(ctx context.Context, userID, nationalID, birthDa shahkarResp.StatusCode, identityResp.StatusCode) } -func (*authService) SendOTPVerifaction(ctx context.Context, phone string) (string, error) { - return "", nil +func (s *authService) SendOTPCode(ctx context.Context, phone string) (string, error) { + otp := domain.NewOTP(phone) + if err := s.otpRepo.Create(ctx, phone, otp); err != nil { + return "", err + } + otpMsg := &sms.OTPMsg{ + Receptor: otp.Phone, + Token: otp.Code, + Template: s.cfg.Kavenegar.Template, + // TODO: make sure when use VerfiyLookup Params + Params: kavenegar.VerifyLookupParam{}, + } + err := s.smsProvider.OTP(otpMsg) + if err != nil { + return "", err + } + + return otp.Code, err +} + +func (s *authService) VerifyOTP(ctx context.Context, phone, code string) error { + otp, err := s.otpRepo.Get(ctx, phone) + if err != nil { + return err + } + + if otp == nil { + return errors.New("otp code not found or expired") + } + + if code != otp.Code { + return errors.New("otp code is not valid") + } + return nil } diff --git a/pkg/sms/kavengar.go b/pkg/sms/kavengar.go new file mode 100644 index 0000000..8e0b980 --- /dev/null +++ b/pkg/sms/kavengar.go @@ -0,0 +1,32 @@ +package sms + +import ( + "backend/config" + + "github.com/kavenegar/kavenegar-go" +) + +type Kavenegar struct { + *kavenegar.Kavenegar +} + +type OTPMsg struct { + Receptor string + Token string + Template string + Params kavenegar.VerifyLookupParam +} + +func NewKavenegar(cfg config.SMSProvider) *Kavenegar { + instance := kavenegar.New(cfg.APIKey) + return &Kavenegar{ + Kavenegar: instance, + } +} + +func (k *Kavenegar) OTP(otpMsg *OTPMsg) error { + if _, err := k.Verify.Lookup(otpMsg.Receptor, otpMsg.Template, otpMsg.Token, &otpMsg.Params); err != nil { + return err + } + return nil +}