Last active
May 26, 2020 08:43
-
-
Save songjiz/851fa120f48049b4a1e4b704c9a2759a to your computer and use it in GitHub Desktop.
One Time Password concern for ActiveRecord
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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