completed SMS gateway project
This commit is contained in:
118
app/models/otp_code.rb
Normal file
118
app/models/otp_code.rb
Normal file
@@ -0,0 +1,118 @@
|
||||
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
|
||||
Reference in New Issue
Block a user