119 lines
3.4 KiB
Ruby
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
|