Skip to content

Instantly share code, notes, and snippets.

@songjiz
Last active May 26, 2020 08:43
Show Gist options
  • Save songjiz/851fa120f48049b4a1e4b704c9a2759a to your computer and use it in GitHub Desktop.
Save songjiz/851fa120f48049b4a1e4b704c9a2759a to your computer and use it in GitHub Desktop.
One Time Password concern for ActiveRecord
module HasOneTimePassword
extend ActiveSupport::Concern
included do
class_attribute :otp_secret_salt,
instance_accessor: false,
default: 'otp'
end
module ClassMethods
# Example using #has_one_time_password
# # Schema User(email: string, otp_secret:string, otp_last_at:datetime)
# class User < ApplicationRecord
# has_one_time_password
# end
#
# user = User.new(email: '[email protected]')
# user.save
# user.otp_secret # => "3FUT4UM7GT4ESCDJ6IUN3GYSM56JIKZH"
# user.otp_last_at # => Sun, 26 Apr 2020 16:38:00 CST +08:00
# user.regenerate_otp_secret # => true
# user.otp_code # => "903690"
# user.otp_uri # => "otpauth://totp/[email protected]?secret=ERF6TAJYZPX2AH5PXPAFQ3O5PDZMVIB3"
# user.authenticate_otp "903690" # => 1587890280
# user.authenticate_otp "903690" # => nil
# user.otp_last_at # Sun, 26 Apr 2020 16:38:15 CST +08:00
#
def has_one_time_password(label: :email, otp_secret: :otp_secret, otp_last_at: :otp_last_at, **options)
begin
require 'rotp'
rescue LoadError
$stderr.puts "You don't have rotp installed in your application. Please add it to your Gemfile and run bundle install"
raise
end
include InstanceMethodsOnActivation.new(label, otp_secret, otp_last_at, **options)
before_create do
send(:"#{otp_secret}=", self.class.generate_otp_random_secret) unless send(:"#{otp_secret}?")
end
end
def generate_otp_random_secret(byte_length = 20)
ROTP::Base32.random byte_length
end
def otp_secret_encryptor
@otp_secret_encryptor ||= ActiveSupport::MessageEncryptor.new(otp_secret_key)
end
def otp_secret_key
Rails.application.key_generator.generate_key(otp_secret_salt, ActiveSupport::MessageEncryptor.key_len)
end
end
class InstanceMethodsOnActivation < Module
def initialize(label, otp_secret, otp_last_at, options)
encrypt = options[:encrypt]
# Basic otp options
digest = options[:digest] || 'sha1'
digits = options[:digits] || 6
# Time based otp options
interval = options[:interval] || 30
issuer = options[:issuer]
define_method :totp do
return if !self.send(:"#{otp_secret}?")
ROTP::TOTP.new(self.send(:"#{otp_secret}"), digits: digits, digest: digest, interval: interval, issuer: issuer)
end
private :totp
define_method :"#{otp_secret}" do
if encrypt && self[otp_secret].present?
self.class.otp_secret_encryptor.decrypt_and_verify(self[otp_secret], purpose: :"#{otp_secret}")
else
super()
end
rescue ActiveSupport::MessageEncryptor::InvalidMessage
super()
end
define_method :"#{otp_secret}=" do |value|
if encrypt
super(self.class.otp_secret_encryptor.encrypt_and_sign(value, purpose: :"#{otp_secret}"))
else
super(value)
end
end
define_method :regenerate_otp_secret do
update! otp_secret => self.class.generate_otp_random_secret
end
define_method :authenticate_otp do |code, **options|
totp&.verify(
code.to_s,
drift_ahead: options[:drift_ahead] || 0,
drift_behind: options[:drift_behind] || 0,
after: options[:after] || (otp_last_at && self.send(:"#{otp_last_at}")),
at: options[:at] || Time.now
).tap { |result|
return unless result && persisted? && otp_last_at && self.class.columns_hash.has_key?("#{otp_last_at}")
touch :"#{otp_last_at}"
}
end
define_method :otp_code do |**options|
totp&.at(options[:time] || Time.now)
end
define_method :otp_provisioning_uri do
totp&.provisioning_uri(resolve_otp_label)
end
alias_method :otp_uri, :otp_provisioning_uri
if defined?(::RQRCode::QRCode)
define_method :otp_qrcode do
::RQRCode::QRCode.new(otp_uri)
end
define_method :otp_qrcode_base64 do |size = 200|
"data:image/png;base64,#{Base64.strict_encode64(otp_qrcode.as_png(size: size).to_s)}"
end
end
define_method :resolve_otp_label do
if label.respond_to?(:call)
if label.arity.zero?
label.call
else
label.call self
end
else
send :"#{label}"
end
end
private :resolve_otp_label
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment