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