feat: kavenegar sdk, otp generation and verifying

This commit is contained in:
AmirMahdi Qiasvand 2025-09-15 16:36:52 +03:30
parent 36cda3fd5a
commit 5829b471d5
9 changed files with 131 additions and 10 deletions

View File

@ -1,12 +1,13 @@
package config package config
type Config struct { type Config struct {
Server Server `mapstructure:"server"` Server Server `mapstructure:"server"`
JWT JWT `mapstructure:"jwt"` JWT JWT `mapstructure:"jwt"`
DB DB `mapstructure:"db"` DB DB `mapstructure:"db"`
Redis Redis `mapstructure:"redis"` Redis Redis `mapstructure:"redis"`
KYCProvider KYC `mapstructure:"kyc"` KYCProvider KYC `mapstructure:"kyc"`
OTP OTP `mapstructure:"otp"` OTP OTP `mapstructure:"otp"`
Kavenegar SMSProvider `mapstructure:"kavenegar"`
} }
type Server struct { type Server struct {
Host string `mapstructure:"host"` Host string `mapstructure:"host"`
@ -41,3 +42,8 @@ type KYC struct {
type OTP struct { type OTP struct {
CodeExpMinutes uint `mapstructure:"code_exp_minutes"` CodeExpMinutes uint `mapstructure:"code_exp_minutes"`
} }
type SMSProvider struct {
APIKey string `mapstructure:"api_key"`
Template string `mapstructure:"template"`
}

1
go.mod
View File

@ -8,6 +8,7 @@ require (
github.com/gofiber/fiber/v2 v2.52.9 github.com/gofiber/fiber/v2 v2.52.9
github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.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/redis/go-redis/v9 v9.13.0
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
github.com/swaggo/http-swagger v1.3.4 github.com/swaggo/http-swagger v1.3.4

2
go.sum
View File

@ -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/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 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 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 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=

View File

@ -24,3 +24,7 @@ type AuthenticateResponse struct {
type RefreshTokenRequest struct { type RefreshTokenRequest struct {
RefreshToken string `json:"refreshToken" validate:"required,jwt"` RefreshToken string `json:"refreshToken" validate:"required,jwt"`
} }
type OTPProviderReq struct {
Receptor string `json:"receptor"`
}

View File

@ -8,6 +8,7 @@ import (
"backend/internal/usecase" "backend/internal/usecase"
cachePackage "backend/pkg/cache" cachePackage "backend/pkg/cache"
"backend/pkg/postgres" "backend/pkg/postgres"
"backend/pkg/sms"
"backend/pkg/zohal" "backend/pkg/zohal"
"fmt" "fmt"
"log" "log"
@ -45,13 +46,17 @@ func NewAppContainer(cfg config.Config) (*AppContainer, error) {
sessionRepo := cache.NewSessionRepository(redis) sessionRepo := cache.NewSessionRepository(redis)
challengeRepo := cache.NewChallengeRepository(redis) challengeRepo := cache.NewChallengeRepository(redis)
zohal := zohal.NewZohal(cfg.KYCProvider) zohal := zohal.NewZohal(cfg.KYCProvider)
otpRepo := cache.NewOTPRepository(redis)
smsProvider := sms.NewKavenegar(cfg.Kavenegar)
authService := usecase.NewAuthService( authService := usecase.NewAuthService(
userRepo, userRepo,
sessionRepo, sessionRepo,
challengeRepo, challengeRepo,
30, 30,
otpRepo,
zohal, zohal,
smsProvider,
cfg, cfg,
) )

View File

@ -10,6 +10,7 @@ import (
type OTPRepo interface { type OTPRepo interface {
Create(ctx context.Context, phone string, otp *OTP) error Create(ctx context.Context, phone string, otp *OTP) error
Get(ctx context.Context, phone string) (*OTP, error)
Delete(ctx context.Context, phone string) error Delete(ctx context.Context, phone string) error
} }
@ -17,7 +18,7 @@ type OTP struct {
ID uuid.UUID ID uuid.UUID
Code string Code string
Phone string Phone string
ExpiredAt time.Time ExpiresAt time.Time
} }
func NewOTP(phone string) *OTP { func NewOTP(phone string) *OTP {
@ -27,3 +28,7 @@ func NewOTP(phone string) *OTP {
Phone: phone, Phone: phone,
} }
} }
func (o *OTP) IsExpired() bool {
return time.Now().After(o.ExpiresAt)
}

View File

@ -7,6 +7,8 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"time" "time"
"github.com/redis/go-redis/v9"
) )
type OTPRepository struct { 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) return fmt.Errorf("failed to marshal otp: %w", err)
} }
ttl := time.Until(otp.ExpiredAt) ttl := time.Until(otp.ExpiresAt)
if ttl <= 0 { if ttl <= 0 {
return fmt.Errorf("OTP expired already") return fmt.Errorf("OTP expired already")
} }
@ -48,6 +50,30 @@ func (r *OTPRepository) Delete(ctx context.Context, phone string) error {
return nil 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 { func (r *OTPRepository) getOTPKey(phone string) string {
return fmt.Sprintf("challenge:%s", phone) return fmt.Sprintf("challenge:%s", phone)
} }

View File

@ -4,6 +4,7 @@ import (
"backend/config" "backend/config"
"backend/internal/domain" "backend/internal/domain"
"backend/pkg/jwt" "backend/pkg/jwt"
"backend/pkg/sms"
"backend/pkg/validate/common" "backend/pkg/validate/common"
"backend/pkg/zohal" "backend/pkg/zohal"
"context" "context"
@ -12,6 +13,7 @@ import (
"time" "time"
jwt2 "github.com/golang-jwt/jwt/v5" jwt2 "github.com/golang-jwt/jwt/v5"
"github.com/kavenegar/kavenegar-go"
"github.com/google/uuid" "github.com/google/uuid"
) )
@ -25,7 +27,9 @@ type authService struct {
userRepo domain.UserRepo userRepo domain.UserRepo
sessionRepo domain.SessionRepo sessionRepo domain.SessionRepo
challengeRepo domain.ChallengeRepo challengeRepo domain.ChallengeRepo
otpRepo domain.OTPRepo
zohal *zohal.Zohal zohal *zohal.Zohal
smsProvider *sms.Kavenegar
challengeExp uint challengeExp uint
cfg config.Config cfg config.Config
} }
@ -41,15 +45,19 @@ func NewAuthService(
sessionRepo domain.SessionRepo, sessionRepo domain.SessionRepo,
challengeRepo domain.ChallengeRepo, challengeRepo domain.ChallengeRepo,
challengeExp uint, challengeExp uint,
otpRepo domain.OTPRepo,
zohal *zohal.Zohal, zohal *zohal.Zohal,
smsProvider *sms.Kavenegar,
cfg config.Config, cfg config.Config,
) AuthService { ) AuthService {
return &authService{ return &authService{
userRepo: userRepo, userRepo: userRepo,
sessionRepo: sessionRepo, sessionRepo: sessionRepo,
challengeRepo: challengeRepo, challengeRepo: challengeRepo,
otpRepo: otpRepo,
challengeExp: challengeExp, challengeExp: challengeExp,
zohal: zohal, zohal: zohal,
smsProvider: smsProvider,
cfg: cfg, cfg: cfg,
} }
} }
@ -190,6 +198,38 @@ func (s *authService) VerifyKYC(ctx context.Context, userID, nationalID, birthDa
shahkarResp.StatusCode, identityResp.StatusCode) shahkarResp.StatusCode, identityResp.StatusCode)
} }
func (*authService) SendOTPVerifaction(ctx context.Context, phone string) (string, error) { func (s *authService) SendOTPCode(ctx context.Context, phone string) (string, error) {
return "", nil 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
} }

32
pkg/sms/kavengar.go Normal file
View File

@ -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
}