Skip to content

Instantly share code, notes, and snippets.

@JunilJacob
Forked from Nitrino/totp_2fa.rb
Created May 31, 2019 09:54
Show Gist options
  • Save JunilJacob/62d3d1188dea852a276178289c3b40bc to your computer and use it in GitHub Desktop.
Save JunilJacob/62d3d1188dea852a276178289c3b40bc to your computer and use it in GitHub Desktop.

Revisions

  1. @Nitrino Nitrino created this gist May 17, 2018.
    116 changes: 116 additions & 0 deletions totp_2fa.rb
    Original file line number Diff line number Diff line change
    @@ -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