|
|
@@ -0,0 +1,116 @@ |
|
|
module OneTimePassword |
|
|
# Concern containing logic and methods for OTP authentication. |
|
|
# Is used Time-based One-time Password Algorithm(TOTP) |
|
|
# https://tools.ietf.org/html/rfc6238 |
|
|
extend ActiveSupport::Concern |
|
|
|
|
|
OTP_DIGITS = 6 |
|
|
OTP_NUMBER_OF_BACKUP_CODES = 10 |
|
|
OTP_BACKUP_CODE_LENGTH = 12 |
|
|
|
|
|
included do |
|
|
before_create do |
|
|
regenerate_otp_secret |
|
|
end |
|
|
end |
|
|
|
|
|
# Generation of a new OTP secret key |
|
|
# After changing the OTP secret key, the provisioning uri will also change |
|
|
def regenerate_otp_secret |
|
|
self.otp_secret_key = ROTP::Base32.random_base32 |
|
|
end |
|
|
|
|
|
# Generation of a list backup codes |
|
|
# 1) Invalidates all existing backup codes |
|
|
# 2) Generates OTP_NUMBER_OF_BACKUP_CODES backup codes |
|
|
# 3) Stores the hashed backup codes in the otp_backup_codes |
|
|
# 4) Returns a plaintext array of the generated backup codes |
|
|
def regenerate_otp_backup_codes |
|
|
codes = Array.new(OTP_NUMBER_OF_BACKUP_CODES).map { SecureRandom.hex(OTP_BACKUP_CODE_LENGTH / 2) } |
|
|
hashed_codes = codes.map { |code| Devise::Encryptor.digest(self.class, code) } |
|
|
self.otp_backup_codes = hashed_codes |
|
|
|
|
|
codes |
|
|
end |
|
|
|
|
|
# Returns true and invalidates the given code |
|
|
# iff that code is a valid backup code. |
|
|
def invalidate_otp_backup_code(code) |
|
|
codes = self.otp_backup_codes || [] |
|
|
|
|
|
codes.each do |backup_code| |
|
|
next unless Devise::Encryptor.compare(self.class, backup_code, code) |
|
|
|
|
|
codes.delete(backup_code) |
|
|
self.otp_backup_codes = codes |
|
|
self.save! |
|
|
return true |
|
|
end |
|
|
|
|
|
false |
|
|
end |
|
|
|
|
|
def otp_is_enabled? |
|
|
self.otp_is_enabled |
|
|
end |
|
|
|
|
|
# Authentication flow is enabled only if otp_is_enabled field is true |
|
|
def enable_otp |
|
|
codes = regenerate_otp_backup_codes |
|
|
self.otp_is_enabled = true |
|
|
self.save! |
|
|
codes |
|
|
end |
|
|
|
|
|
def disable_otp |
|
|
self.otp_is_enabled = false |
|
|
self.save |
|
|
end |
|
|
|
|
|
# Shows the current OTP code. |
|
|
# The code is similar to what will be displayed in the Google authenticator. |
|
|
# You can use it to authenticate via SMS and for testing. |
|
|
# @param time [Time] Time for code verification. By default is the current time. |
|
|
# Explicit changing the test time is convenient for testing |
|
|
def otp_code(time = Time.current) |
|
|
ROTP::TOTP.new(self.otp_secret_key, digits: OTP_DIGITS).at(time, true) |
|
|
end |
|
|
|
|
|
# Method for verifying OTP code |
|
|
# @param code [String] OTP code. |
|
|
# @param drift [Integer] Allows to confirm the OTP code within 15 seconds after the expiration of its time. |
|
|
# Drift allows you to level out the inaccuracy of time on different devices and server |
|
|
# By default, the drift is set to 15 seconds. |
|
|
# @return [Boolean] OTP code verification result |
|
|
def authenticate_otp(code, drift = 15) |
|
|
totp = ROTP::TOTP.new(self.otp_secret_key, digits: OTP_DIGITS) |
|
|
totp.verify_with_drift(code, drift) |
|
|
end |
|
|
|
|
|
# Method for combining otp authorization and code recovery |
|
|
def authenticate_otp_or_invalidate_otp_backup_code(code) |
|
|
authenticate_otp(code) || invalidate_otp_backup_code(code) |
|
|
end |
|
|
|
|
|
# URI compatible with the Google Authenticator App to be scanned with the in-built QR Code scanner. |
|
|
# @param account [String] account name for which the Provisioning URI is generated. By default user email address |
|
|
# @param options [Hash] options for generate provisioning uri |
|
|
# By default is blank hash. |
|
|
def provisioning_uri(account: nil, options: {}) |
|
|
account ||= self.attributes["email"] |
|
|
options[:issuer] ||= otp_issuer |
|
|
ROTP::TOTP.new(self.otp_secret_key, options).provisioning_uri(account) |
|
|
end |
|
|
|
|
|
# Exclude the OTP secret key from all serializers |
|
|
def serializable_hash(options = nil) |
|
|
options ||= {} |
|
|
options[:except] = Array(options[:except]) |
|
|
options[:except] << "otp_secret_key" |
|
|
super(options) |
|
|
end |
|
|
|
|
|
def otp_issuer |
|
|
raise "You must override `otp_issuer` method in #{self.class.name} model" |
|
|
end |
|
|
end |