Files
MySMSAPio/app/models/otp_code.rb
2025-10-22 17:22:17 +08:00

119 lines
3.4 KiB
Ruby

class OtpCode < ApplicationRecord
# Normalize metadata to always be a Hash
attribute :metadata, :jsonb, default: {}
validates :phone_number, presence: true
validates :code, presence: true, length: { is: 6 }
validates :expires_at, presence: true
validates :purpose, presence: true
validate :phone_number_format
validate :rate_limit_check, on: :create
before_validation :ensure_metadata_is_hash
before_validation :generate_code, on: :create, unless: :code?
before_validation :set_expiry, on: :create, unless: :expires_at?
before_validation :normalize_phone_number
scope :valid_codes, -> { where(verified: false).where("expires_at > ?", Time.current) }
scope :expired, -> { where("expires_at <= ?", Time.current) }
scope :verified_codes, -> { where(verified: true) }
# Verify an OTP code
def self.verify(phone_number, code)
normalized_phone = normalize_phone_number_string(phone_number)
otp = valid_codes.find_by(phone_number: normalized_phone, code: code)
return { success: false, error: "Invalid or expired OTP", attempts_remaining: 0 } unless otp
otp.increment!(:attempts)
# Lock out after 3 failed attempts
if otp.attempts > 3
otp.update!(expires_at: Time.current)
return { success: false, error: "Too many attempts. OTP expired.", attempts_remaining: 0 }
end
# Successfully verified
otp.update!(verified: true, verified_at: Time.current)
{ success: true, verified: true }
end
# Generate and send OTP
def self.send_otp(phone_number, purpose: "authentication", expiry_minutes: 5, ip_address: nil)
normalized_phone = normalize_phone_number_string(phone_number)
otp = create!(
phone_number: normalized_phone,
purpose: purpose,
expires_at: expiry_minutes.minutes.from_now,
ip_address: ip_address
)
# Create SMS message for sending
sms = SmsMessage.create!(
direction: "outbound",
phone_number: normalized_phone,
message_body: "Your OTP code is: #{otp.code}. Valid for #{expiry_minutes} minutes. Do not share this code."
)
{ otp: otp, sms: sms }
end
# Clean up expired OTP codes
def self.cleanup_expired!
expired.where(verified: false).delete_all
end
# Check if OTP is still active and usable
def active_and_usable?
!verified && expires_at > Time.current && attempts < 3
end
private
def generate_code
self.code = format("%06d", SecureRandom.random_number(1_000_000))
end
def set_expiry
self.expires_at = 5.minutes.from_now
end
def normalize_phone_number
return unless phone_number.present?
self.phone_number = self.class.normalize_phone_number_string(phone_number)
end
def self.normalize_phone_number_string(number)
number.gsub(/[^\d+]/, "")
end
def phone_number_format
return unless phone_number.present?
phone = Phonelib.parse(phone_number)
unless phone.valid?
errors.add(:phone_number, "is not a valid phone number")
end
end
def rate_limit_check
return unless phone_number.present?
# Max 3 OTP per phone per hour
recent_count = OtpCode.where(phone_number: phone_number)
.where("created_at > ?", 1.hour.ago)
.count
if recent_count >= 3
errors.add(:base, "Rate limit exceeded. Maximum 3 OTP codes per hour.")
end
end
def ensure_metadata_is_hash
self.metadata = {} if metadata.nil?
self.metadata = {} unless metadata.is_a?(Hash)
end
end