2823fb45f5
Phone+OTP auth, staged onboarding, admin approval workflow, and a full discovery/swipe/match/chat system with photo upload and approximate distance (haversine) based on user location. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
188 lines
5.6 KiB
Go
188 lines
5.6 KiB
Go
package service
|
|
|
|
import (
|
|
"errors"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"dating-app/internal/models"
|
|
"dating-app/internal/repository"
|
|
"dating-app/internal/utils"
|
|
)
|
|
|
|
var (
|
|
ErrPhoneBlocked = errors.New("حساب کاربری مسدود شده است")
|
|
ErrOTPStillValid = errors.New("کد تایید قبلی هنوز معتبر است")
|
|
ErrOTPNotRequested = errors.New("ابتدا باید کد تایید را درخواست کنید")
|
|
ErrOTPExpired = errors.New("کد تایید منقضی شده است")
|
|
ErrOTPInvalid = errors.New("کد وارد شده نامعتبر است")
|
|
ErrSMSSendFailed = errors.New("ارسال پیامک با خطا مواجه شد؛ لطفاً چند لحظه دیگر دوباره تلاش کنید")
|
|
ErrUserNotFound = errors.New("کاربر یافت نشد")
|
|
)
|
|
|
|
// OTPStillValidError برای برگردوندن تعداد ثانیهی باقیمانده تا انقضای کد قبلی
|
|
type OTPStillValidError struct {
|
|
RemainingSeconds int
|
|
}
|
|
|
|
func (e *OTPStillValidError) Error() string {
|
|
return "کد تایید قبلی هنوز معتبر است"
|
|
}
|
|
|
|
type AuthService struct {
|
|
userRepo *repository.UserRepository
|
|
otpSender OTPSender
|
|
jwtManager *utils.JWTManager
|
|
|
|
otpExpiry time.Duration
|
|
otpLength int
|
|
otpHashSecret string
|
|
}
|
|
|
|
func NewAuthService(
|
|
userRepo *repository.UserRepository,
|
|
otpSender OTPSender,
|
|
jwtManager *utils.JWTManager,
|
|
otpExpirySeconds int,
|
|
otpLength int,
|
|
otpHashSecret string,
|
|
) *AuthService {
|
|
return &AuthService{
|
|
userRepo: userRepo,
|
|
otpSender: otpSender,
|
|
jwtManager: jwtManager,
|
|
otpExpiry: time.Duration(otpExpirySeconds) * time.Second,
|
|
otpLength: otpLength,
|
|
otpHashSecret: otpHashSecret,
|
|
}
|
|
}
|
|
|
|
// RequestOTP یک کد تایید جدید تولید، هش و ذخیره میکند و از طریق OTPSender ارسال میکند.
|
|
func (s *AuthService) RequestOTP(phone string) error {
|
|
if err := utils.ValidatePhone(phone); err != nil {
|
|
return err
|
|
}
|
|
|
|
user, _, err := s.userRepo.GetOrCreateByPhone(phone)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if user.State == models.UserStateBlocked {
|
|
return ErrPhoneBlocked
|
|
}
|
|
|
|
// اگر کد قبلی هنوز معتبره، اجازهی ارسال دوباره نده
|
|
if user.OTPCode != nil && user.OTPExpiry != nil && time.Now().Before(*user.OTPExpiry) {
|
|
remaining := int(time.Until(*user.OTPExpiry).Seconds())
|
|
return &OTPStillValidError{RemainingSeconds: remaining}
|
|
}
|
|
|
|
otp := utils.GenerateOTP(s.otpLength)
|
|
hashed := utils.HashOTP(otp, s.otpHashSecret)
|
|
expiry := time.Now().Add(s.otpExpiry)
|
|
|
|
if err := s.userRepo.UpdateFields(user, map[string]interface{}{
|
|
"otp_code": hashed,
|
|
"otp_expiry": expiry,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
// ارسال پیامک بهصورت sync؛ اگه واقعاً شکست خورد، صادقانه به کاربر خبر بده
|
|
// و کد ذخیرهشده رو پاک کن تا کاربر بلافاصله بتونه دوباره تلاش کنه
|
|
// (وگرنه تا پایان OTPExpiry با پیام «کد قبلی هنوز معتبره» قفل میمونه).
|
|
if ok := s.otpSender.Send(phone, otp); !ok {
|
|
_ = s.userRepo.UpdateFields(user, map[string]interface{}{
|
|
"otp_code": nil,
|
|
"otp_expiry": nil,
|
|
})
|
|
return ErrSMSSendFailed
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// VerifyOTP کد را بررسی میکند و در صورت صحت، کاربر را فعال کرده و توکن صادر میکند.
|
|
func (s *AuthService) VerifyOTP(phone, otp string) (*models.User, string, string, bool, error) {
|
|
user, err := s.userRepo.FindByPhone(phone)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, "", "", false, ErrUserNotFound
|
|
}
|
|
return nil, "", "", false, err
|
|
}
|
|
|
|
if user.OTPCode == nil || user.OTPExpiry == nil {
|
|
return nil, "", "", false, ErrOTPNotRequested
|
|
}
|
|
|
|
if time.Now().After(*user.OTPExpiry) {
|
|
_ = s.userRepo.UpdateFields(user, map[string]interface{}{
|
|
"otp_code": nil,
|
|
"otp_expiry": nil,
|
|
})
|
|
return nil, "", "", false, ErrOTPExpired
|
|
}
|
|
|
|
if *user.OTPCode != utils.HashOTP(otp, s.otpHashSecret) {
|
|
return nil, "", "", false, ErrOTPInvalid
|
|
}
|
|
|
|
isNewUser := user.State == models.UserStateInactive
|
|
now := time.Now()
|
|
|
|
updates := map[string]interface{}{
|
|
"otp_code": nil,
|
|
"otp_expiry": nil,
|
|
"state": models.UserStateActive,
|
|
"last_login": now,
|
|
}
|
|
if err := s.userRepo.UpdateFields(user, updates); err != nil {
|
|
return nil, "", "", false, err
|
|
}
|
|
// رفرش شیء user در حافظه برای برگردوندن مقادیر بهروز
|
|
user.State = models.UserStateActive
|
|
user.LastLogin = &now
|
|
user.OTPCode = nil
|
|
user.OTPExpiry = nil
|
|
|
|
access, err := s.jwtManager.GenerateAccessToken(user.ID, user.IsAdmin)
|
|
if err != nil {
|
|
return nil, "", "", false, err
|
|
}
|
|
refresh, err := s.jwtManager.GenerateRefreshToken(user.ID, user.IsAdmin)
|
|
if err != nil {
|
|
return nil, "", "", false, err
|
|
}
|
|
|
|
return user, access, refresh, isNewUser, nil
|
|
}
|
|
|
|
// RefreshTokens با یک refresh token معتبر، یک جفت توکن جدید صادر میکند.
|
|
func (s *AuthService) RefreshTokens(refreshToken string) (string, string, error) {
|
|
claims, err := s.jwtManager.ParseRefreshToken(refreshToken)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
user, err := s.userRepo.FindByID(claims.UserID)
|
|
if err != nil {
|
|
return "", "", ErrUserNotFound
|
|
}
|
|
if user.State == models.UserStateBlocked || !user.IsActive {
|
|
return "", "", ErrPhoneBlocked
|
|
}
|
|
|
|
access, err := s.jwtManager.GenerateAccessToken(user.ID, user.IsAdmin)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
newRefresh, err := s.jwtManager.GenerateRefreshToken(user.ID, user.IsAdmin)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return access, newRefresh, nil
|
|
}
|