package usecase import ( "backend/config" "backend/internal/domain" "backend/pkg/jwt" "backend/pkg/sms" "backend/pkg/validate/common" "backend/pkg/zohal" "context" "errors" "fmt" "time" jwt2 "github.com/golang-jwt/jwt/v5" "github.com/kavenegar/kavenegar-go" "github.com/google/uuid" ) type AuthService interface { GenerateChallenge(ctx context.Context, pubKey string) (*domain.Challenge, error) Authenticate(ctx context.Context, pubKey string, signature string, chainID uint, ipAddress, userAgent string) (*UserToken, error) SendOTPCode(ctx context.Context, phone string) (string, error) VerifyOTP(ctx context.Context, phone, code string) error VerifyKYC(ctx context.Context, userID, nationalID, birthDate string) error } 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 } type UserToken struct { AuthorizationToken string RefreshToken string ExpiresAt int64 } func NewAuthService( userRepo domain.UserRepo, 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, } } func (s *authService) GenerateChallenge(ctx context.Context, pubKey string) (*domain.Challenge, error) { _, err := common.IsValidPublicKey(pubKey) if err != nil { return nil, err } challenge := &domain.Challenge{ Message: uuid.New().String(), TimeStamp: time.Now().UTC(), ExpiresAt: time.Now().Add(time.Duration(s.challengeExp) * time.Minute), } // Save challenge to Redis err = s.challengeRepo.Create(ctx, pubKey, challenge) if err != nil { return nil, fmt.Errorf("failed to save challenge: %w", err) } return challenge, nil } func (s *authService) Authenticate(ctx context.Context, pubKey string, signature string, chainID uint, ipAddress, userAgent string) (*UserToken, error) { _, err := common.IsValidPublicKey(pubKey) if err != nil { return nil, fmt.Errorf("invalid public key: %w", err) } // Retrieve challenge from Redis challenge, err := s.challengeRepo.GetByPubKey(ctx, pubKey) if err != nil { return nil, fmt.Errorf("failed to retrieve challenge: %w", err) } isValid, err := challenge.Verify(pubKey, signature) if err != nil { return nil, fmt.Errorf("signature verification failed: %w", err) } if !isValid { return nil, errors.New("invalid signature") } // Delete the challenge after successful verification to prevent replay attacks err = s.challengeRepo.Delete(ctx, pubKey) if err != nil { return nil, fmt.Errorf("failed to delete challenge: %w", err) } user, err := s.userRepo.GetOrCreateUserByPubKey(ctx, pubKey) if err != nil { return nil, fmt.Errorf("failed to get or create user: %w", err) } user.UpdateLastLogin() if err := s.userRepo.Update(ctx, user); err != nil { return nil, fmt.Errorf("failed to update user: %w", err) } expiresAt := time.Now().Add(time.Duration(s.cfg.JWT.TokenExpMinutes) * time.Minute) session := domain.NewSession(user.ID, user.PubKey, expiresAt) err = s.sessionRepo.Create(ctx, session) if err != nil { return nil, fmt.Errorf("failed to create session: %w", err) } //FIXME: KYC level will be set to 1 claims := &jwt.UserClaims{ UserID: uint(user.ID.ID()), KYCLevel: int(user.KYCLevel), Sections: []string{}, } claims.ExpiresAt = jwt2.NewNumericDate(expiresAt) claims.IssuedAt = jwt2.NewNumericDate(time.Now()) authToken, err := jwt.CreateToken([]byte(s.cfg.JWT.TokenSecret), claims) if err != nil { return nil, err } refreshExpiresAt := time.Now().Add(time.Duration(s.cfg.JWT.RefreshTokenExpMinutes) * time.Minute) refreshClaims := &jwt.UserClaims{ UserID: uint(user.ID.ID()), KYCLevel: int(user.KYCLevel), Sections: []string{"refresh"}, } refreshClaims.ExpiresAt = jwt2.NewNumericDate(refreshExpiresAt) refreshClaims.IssuedAt = jwt2.NewNumericDate(time.Now()) refreshToken, err := jwt.CreateToken([]byte(s.cfg.JWT.TokenSecret), refreshClaims) if err != nil { return nil, fmt.Errorf("failed to create refresh token: %w", err) } return &UserToken{ AuthorizationToken: authToken, RefreshToken: refreshToken, ExpiresAt: expiresAt.Unix(), }, nil } func (s *authService) VerifyKYC(ctx context.Context, userID, nationalID, birthDate string) error { uID, err := uuid.Parse(userID) if err != nil { return fmt.Errorf("invalid user ID format: %w", err) } user, err := s.userRepo.GetByID(ctx, uID) if err != nil { return fmt.Errorf("failed to get user: %w", err) } shahkarResp, err := s.zohal.Shahkar(ctx, user.PhoneNumber, user.NationalID) if err != nil { return fmt.Errorf("shahkar verification failed: %w", err) } identityResp, err := s.zohal.GetPerson(ctx, nationalID, birthDate) if err != nil { return fmt.Errorf("identity verification failed: %w", err) } if shahkarResp.StatusCode == 200 && identityResp.StatusCode == 200 { user.UpdateKYCLevel(domain.KYCLevel1) err = s.userRepo.Update(ctx, user) if err != nil { return fmt.Errorf("failed to update user KYC level: %w", err) } return nil } return fmt.Errorf("KYC verification failed - shahkar status: %d, identity status: %d", shahkarResp.StatusCode, identityResp.StatusCode) } 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 }