├── .rspec ├── lib ├── google-authenticator-rails │ ├── version.rb │ ├── action_controller.rb │ ├── active_record.rb │ ├── session.rb │ ├── session │ │ ├── base.rb │ │ ├── activation.rb │ │ └── persistence.rb │ ├── action_controller │ │ └── rails_adapter.rb │ └── active_record │ │ ├── helpers.rb │ │ └── acts_as_google_authenticated.rb ├── google-authenticator-rails.rb └── tasks │ └── google_authenticator.rake ├── Gemfile ├── spec ├── support │ └── application_controller.rb ├── action_controller │ ├── rails_adapter_spec.rb │ └── integration_spec.rb ├── session │ ├── activation_spec.rb │ └── persistance_spec.rb ├── spec_helper.rb └── google_authenticator_spec.rb ├── gemfiles ├── rails_6.1.gemfile └── rails_7.0.gemfile ├── Appraisals ├── config └── secrets.yml ├── .gitignore ├── CONTRIBUTING.md ├── Rakefile ├── LICENSE ├── .circleci └── config.yml ├── google-authenticator.gemspec ├── CODE_OF_CONDUCT.md ├── Gemfile.lock └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --colour -------------------------------------------------------------------------------- /lib/google-authenticator-rails/version.rb: -------------------------------------------------------------------------------- 1 | module GoogleAuthenticatorRails 2 | VERSION = "3.4.3" 3 | end 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in google-authenticator.gemspec 4 | gemspec -------------------------------------------------------------------------------- /lib/google-authenticator-rails/action_controller.rb: -------------------------------------------------------------------------------- 1 | require 'google-authenticator-rails/action_controller/rails_adapter' -------------------------------------------------------------------------------- /spec/support/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < MockControllerWithApplicationController 2 | end 3 | -------------------------------------------------------------------------------- /gemfiles/rails_6.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 6.1.0" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/rails_7.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.0.0" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /lib/google-authenticator-rails/active_record.rb: -------------------------------------------------------------------------------- 1 | require 'google-authenticator-rails/active_record/acts_as_google_authenticated' 2 | require 'google-authenticator-rails/active_record/helpers' -------------------------------------------------------------------------------- /lib/google-authenticator-rails/session.rb: -------------------------------------------------------------------------------- 1 | SESSION_GOOGLE_AUTHENTICATOR_RAILS_PATH = GOOGLE_AUTHENTICATOR_RAILS_PATH + "session/" 2 | 3 | [ 4 | "activation", 5 | "persistence", 6 | 7 | "base" 8 | ].each do |library| 9 | require SESSION_GOOGLE_AUTHENTICATOR_RAILS_PATH + library 10 | end -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | version_info = RUBY_VERSION.split(".") 2 | 3 | major = version_info.first.to_i 4 | minor = version_info[1].to_i 5 | hotfix = version_info.last.to_i 6 | 7 | appraise "rails-6.1" do 8 | gem "activerecord", "~> 6.1.0" 9 | end 10 | 11 | appraise "rails-7.0" do 12 | gem "activerecord", "~> 7.0.0" 13 | end -------------------------------------------------------------------------------- /config/secrets.yml: -------------------------------------------------------------------------------- 1 | test: 2 | secret_key_base: ea13d27b89aaa5004212cc78552e2a1f0ceaa21f797de0089277506a036b1d450f8508eb51b838c04af477c6d03d7c8b0f3258937dc291935eb49c2a8f3fa9d9 3 | old_secret_key_base: c9e4baab86ab1d7373eafd7fbc467b227d44e23608834ea5bdfc502bd33211d205f245a06ced789af579e8d77aa4c1bfd605065190ee24d3709ada92366cadfb 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | .rvmrc 7 | .rspec 8 | .ruby-gemset 9 | .ruby-version 10 | gemfiles/*.lock 11 | InstalledFiles 12 | _yardoc 13 | coverage 14 | doc/ 15 | lib/bundler/man 16 | pkg 17 | rdoc 18 | spec/reports 19 | test/tmp 20 | test/version_tmp 21 | tmp 22 | vendor 23 | Dockerfile 24 | docker-compose.yml 25 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | 1. Fork it 4 | 2. Create your feature branch (`git checkout -b my-new-feature`) 5 | 3. Commit your changes (`git commit -am 'Added some feature'`) 6 | 4. Push to the branch (`git push origin my-new-feature`) 7 | 5. Create new Pull Request 8 | 9 | ## Bug fixes 10 | 11 | It's very helpful for me if you include a failing spec with your bug fix so I can verify the behavior that is changing. -------------------------------------------------------------------------------- /spec/action_controller/rails_adapter_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe GoogleAuthenticatorRails::ActionController::RailsAdapter do 4 | describe '#cookies' do 5 | let(:controller) { MockController.new } 6 | let(:adapter) { GoogleAuthenticatorRails::ActionController::RailsAdapter.new(controller) } 7 | 8 | after { adapter.cookies } 9 | specify { controller.should_receive(:cookies) } 10 | end 11 | end -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | require "bundler/setup" 3 | require "bundler/gem_tasks" 4 | require "appraisal" 5 | 6 | begin 7 | # RSpec 2 8 | require "rspec/core/rake_task" 9 | 10 | RSpec::Core::RakeTask.new do |t| 11 | t.pattern = "spec/**/*_spec.rb" 12 | t.rspec_opts = "--color --format documentation --backtrace" 13 | end 14 | rescue LoadError 15 | # RSpec 1 16 | require "spec/rake/spectask" 17 | 18 | Spec::Rake::SpecTask.new(:spec) do |t| 19 | t.pattern = "spec/**/*_spec.rb" 20 | t.spec_opts = ["--color", "--format nested", "--backtrace"] 21 | end 22 | end 23 | 24 | desc "Default: Test the gem under all supported Rails versions." 25 | task :default => ["appraisal:install"] do 26 | exec("rake appraisal spec") 27 | end 28 | 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Jared McFarland 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /lib/google-authenticator-rails/session/base.rb: -------------------------------------------------------------------------------- 1 | module GoogleAuthenticatorRails 2 | module Session 3 | # This is where the heart of the session control logic works. 4 | # GoogleAuthenticatorRails works in the same way as Authlogic. It assumes that you've created a class based on 5 | # GoogleAuthenticatorRails::Session::Base with the name of the model you want to authenticate + "MfaSession". So if you had 6 | # 7 | # class User < ActiveRecord::Base 8 | # end 9 | # 10 | # Your Session management class would look like 11 | # 12 | # class UserMfaSession < GoogleAuthenticatorRails::Session::Base 13 | # end 14 | # 15 | # The Session class gets the name of the record to lookup from the name of the class. 16 | # 17 | # To create a new session based off our User class, you just call 18 | # 19 | # UserMfaSession.create(@user) # => <# UserMfaSession @record="<# User >"> 20 | # 21 | # Then, in your controller, you can lookup that session by calling 22 | # 23 | # UserMfaSession.find 24 | # 25 | # You don't have to pass any arguments because only one session can be active at a time. 26 | # 27 | class Base 28 | include Activation 29 | include Persistence 30 | end 31 | end 32 | end -------------------------------------------------------------------------------- /spec/action_controller/integration_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe GoogleAuthenticatorRails::ActionController::Integration do 4 | describe '::included' do 5 | context 'ApplicationController already defined' do 6 | # Autoload ApplicationController. 7 | before { ApplicationController } 8 | subject { lambda { MockControllerWithApplicationController.send(:include, GoogleAuthenticatorRails::ActionController::Integration) } } 9 | 10 | it { should raise_error(GoogleAuthenticatorRails::ActionController::RailsAdapter::LoadedTooLateError) } 11 | end 12 | 13 | it 'should add the before filter' do 14 | MockController.should_receive(:prepend_before_filter).with(:activate_google_authenticator_rails) 15 | MockController.send(:include, GoogleAuthenticatorRails::ActionController::Integration) 16 | end 17 | end 18 | 19 | describe '::activate_google_authenticator_rails' do 20 | let(:controller) { MockController.new } 21 | 22 | before do 23 | MockController.send(:include, GoogleAuthenticatorRails::ActionController::Integration) 24 | controller.send(:activate_google_authenticator_rails) 25 | end 26 | 27 | specify { GoogleAuthenticatorRails::Session::Base.controller.should be_a GoogleAuthenticatorRails::ActionController::RailsAdapter } 28 | end 29 | end -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Use the latest 2.1 version of CircleCI pipeline process engine. 2 | # See: https://circleci.com/docs/2.0/configuration-reference 3 | version: 2.1 4 | 5 | orbs: 6 | ruby: circleci/ruby@1.8.0 7 | 8 | # Define a job to be invoked later in a workflow. 9 | # See: https://circleci.com/docs/2.0/configuration-reference/#jobs 10 | jobs: 11 | test: 12 | parameters: 13 | ruby: 14 | type: string 15 | rails: 16 | type: string 17 | docker: 18 | - image: cimg/ruby:<< parameters.ruby >> 19 | steps: 20 | - checkout 21 | - ruby/install-deps: # use the ruby orb to install dependencies 22 | bundler-version: 2.4.10 23 | - run: 24 | name: Install appraisal deps 25 | command: bundle exec appraisal install 26 | - run: 27 | name: Update appraisal deps 28 | command: bundle exec appraisal update 29 | - run: 30 | name: Run all appraisal tests 31 | command: bundle exec appraisal << parameters.rails >> rake spec 32 | 33 | # Invoke jobs via workflows 34 | # See: https://circleci.com/docs/2.0/configuration-reference/#workflows 35 | workflows: 36 | all-tests: 37 | jobs: 38 | - test: 39 | matrix: 40 | parameters: 41 | ruby: ["3.2", "3.1", "3.0"] 42 | rails: ["rails-6.1", "rails-7.0"] 43 | exclude: 44 | - ruby: "3.1" 45 | rails: "rails-6.1" 46 | -------------------------------------------------------------------------------- /lib/google-authenticator-rails/session/activation.rb: -------------------------------------------------------------------------------- 1 | module GoogleAuthenticatorRails 2 | module Session 3 | module Activation 4 | class ControllerMissingError < StandardError; end 5 | 6 | def self.included(klass) 7 | klass.class_eval do 8 | extend ClassMethods 9 | include InstanceMethods 10 | end 11 | end 12 | 13 | end 14 | 15 | module ClassMethods 16 | # Every thread in Passenger handles only a single request at a time, but there can be many threads running. 17 | # This ensures that when setting the current active controller 18 | # it only gets set for the current active thread (and doesn't mess up any other threads). 19 | # 20 | def controller=(controller) 21 | Thread.current[:google_authenticator_rails_controller] = controller 22 | end 23 | 24 | def controller 25 | Thread.current[:google_authenticator_rails_controller] 26 | end 27 | 28 | # If the controller isn't set, we can't use the Sessions. They rely on the session information passed 29 | # in from ActionController to access the cookies. 30 | # 31 | def activated? 32 | !controller.nil? 33 | end 34 | end 35 | 36 | module InstanceMethods 37 | attr_reader :record 38 | 39 | def initialize(record) 40 | raise Activation::ControllerMissingError unless self.class.activated? 41 | 42 | @record = record 43 | end 44 | 45 | private 46 | def controller 47 | self.class.controller 48 | end 49 | end 50 | end 51 | end -------------------------------------------------------------------------------- /google-authenticator.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | require File.expand_path('../lib/google-authenticator-rails/version', __FILE__) 3 | 4 | version_info = RUBY_VERSION.split(".") 5 | 6 | major = version_info.first.to_i 7 | minor = version_info[1].to_i 8 | hotfix = version_info.last.to_i 9 | 10 | Gem::Specification.new do |gem| 11 | gem.authors = ["Jared McFarland"] 12 | gem.email = ["jared.online@gmail.com"] 13 | gem.description = %q{Add the ability to use the Google Authenticator with ActiveRecord.} 14 | gem.summary = %q{Add the ability to use the Google Authenticator with ActiveRecord.} 15 | gem.homepage = "http://github.com/jaredonline/google-authenticator" 16 | 17 | gem.files = Dir['lib/**/*.{rb,rake}'] + Dir['bin/*'] 18 | gem.files += Dir['[A-Z]*'] + Dir['spec/**/*.rb'] 19 | gem.files.reject! { |fn| fn.include? "CVS" } 20 | 21 | gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } 22 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 23 | gem.name = "google-authenticator-rails" 24 | gem.require_paths = ["lib"] 25 | gem.version = GoogleAuthenticatorRails::VERSION 26 | 27 | gem.add_dependency "rotp", ">= 5.0", "< 7.0" 28 | gem.add_dependency "rails" 29 | gem.add_dependency "activerecord" 30 | gem.add_dependency "rqrcode" 31 | gem.add_dependency "actionpack" 32 | 33 | gem.add_development_dependency "rake", "~> 13.0" 34 | gem.add_development_dependency "rspec", "~> 3.4.0" 35 | gem.add_development_dependency "appraisal", "~> 2.5.0" 36 | gem.add_development_dependency "simplecov" 37 | gem.add_development_dependency "sqlite3", "~> 1.7" 38 | end 39 | -------------------------------------------------------------------------------- /lib/google-authenticator-rails/action_controller/rails_adapter.rb: -------------------------------------------------------------------------------- 1 | module GoogleAuthenticatorRails 2 | module ActionController 3 | class RailsAdapter 4 | class LoadedTooLateError < StandardError 5 | def initialize 6 | super("GoogleAuthenticatorRails is trying to prepend a before_filter in ActionController::Base. Because you've already defined" + 7 | " ApplicationController, your controllers will not get this before_filter. Please load GoogleAuthenticatorRails before defining" + 8 | " ApplicationController.") 9 | end 10 | end 11 | 12 | def initialize(controller) 13 | @controller = controller 14 | end 15 | 16 | def cookies 17 | @controller.send(:cookies) 18 | end 19 | end 20 | 21 | module Integration 22 | def self.included(klass) 23 | if klass.descendants.map(&:name).include?("ApplicationController") 24 | raise RailsAdapter::LoadedTooLateError.new 25 | end 26 | 27 | method = klass.respond_to?(:prepend_before_action) ? :prepend_before_action : :prepend_before_filter 28 | klass.send(method, :activate_google_authenticator_rails) 29 | end 30 | 31 | private 32 | def activate_google_authenticator_rails 33 | GoogleAuthenticatorRails::Session::Base.controller = RailsAdapter.new(self) 34 | end 35 | end 36 | end 37 | end 38 | 39 | ActiveSupport.on_load(:action_controller) do 40 | if defined?(ActionController::Base) 41 | ActionController::Base.send(:include, GoogleAuthenticatorRails::ActionController::Integration) 42 | end 43 | 44 | if defined?(ActionController::API) 45 | ActionController::API.send(:include, GoogleAuthenticatorRails::ActionController::Integration) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/session/activation_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe GoogleAuthenticatorRails::Session::Base do 4 | describe 'ClassMethods' do 5 | context 'thread safety' do 6 | let(:thread_count) { 100 } 7 | let(:controllers) { thread_count.times.map { MockController.new } } 8 | let(:threads) do 9 | controllers.map do |controller| 10 | Thread.new do 11 | GoogleAuthenticatorRails::Session::Base.controller = controller 12 | Thread.current[:test_case_controller] = GoogleAuthenticatorRails::Session::Base.controller 13 | end 14 | end 15 | end 16 | 17 | before do 18 | GoogleAuthenticatorRails::Session::Base.controller = nil 19 | sleep(0.01) while threads.any?(&:status) 20 | end 21 | 22 | specify { GoogleAuthenticatorRails::Session::Base.controller.should be_nil } 23 | specify { threads.map { |thread| thread[:test_case_controller].object_id }.should eq controllers.map(&:object_id) } 24 | end 25 | 26 | describe '::activated?' do 27 | subject { GoogleAuthenticatorRails::Session::Base.activated? } 28 | before { GoogleAuthenticatorRails::Session::Base.controller = controller } 29 | 30 | context 'controller present' do 31 | let(:controller) { MockController.new } 32 | it { should be true } 33 | end 34 | 35 | context 'controller missing' do 36 | let(:controller) { nil } 37 | it { should be false } 38 | end 39 | end 40 | end 41 | 42 | describe 'InstanceMethods' do 43 | describe '#initialize' do 44 | context 'controller missing' do 45 | before { GoogleAuthenticatorRails::Session::Base.controller = nil } 46 | subject { lambda { GoogleAuthenticatorRails::Session::Base.new(nil) } } 47 | it { should raise_error(GoogleAuthenticatorRails::Session::Activation::ControllerMissingError) } 48 | end 49 | end 50 | end 51 | end -------------------------------------------------------------------------------- /lib/google-authenticator-rails.rb: -------------------------------------------------------------------------------- 1 | # Stuff the gem requires 2 | # 3 | require 'active_support' 4 | require 'active_record' 5 | require 'openssl' 6 | require 'rotp' 7 | require 'rqrcode' 8 | 9 | # Stuff the gem is 10 | # 11 | GOOGLE_AUTHENTICATOR_RAILS_PATH = File.dirname(__FILE__) + "/google-authenticator-rails/" 12 | 13 | [ 14 | "version", 15 | 16 | "action_controller", 17 | "active_record", 18 | "session" 19 | ].each do |library| 20 | require GOOGLE_AUTHENTICATOR_RAILS_PATH + library 21 | end 22 | 23 | # Sets up some basic accessors for use with the ROTP module 24 | # 25 | module GoogleAuthenticatorRails 26 | def self.encryption_supported? 27 | defined?(Rails) && (Rails::VERSION::MAJOR > 4 || Rails::VERSION::MAJOR == 4 && Rails::VERSION::MINOR > 0) 28 | end 29 | 30 | class Railtie < Rails::Railtie 31 | rake_tasks do 32 | load 'tasks/google_authenticator.rake' 33 | end 34 | end if encryption_supported? && !Rails.env.test? # Without this last condition tasks under test are run twice 35 | 36 | # Drift is set to 6 because ROTP drift is not inclusive. This allows a drift of 5 seconds. 37 | DRIFT = 6 38 | 39 | # How long a Session::Persistence cookie should last. 40 | @@time_until_expiration = 24.hours 41 | 42 | # Last part of a Session::Persistence cookie's key 43 | @@cookie_key_suffix = nil 44 | 45 | # Additional configuration passed to a Session::Persistence cookie. 46 | @@cookie_options = { :httponly => true } 47 | 48 | def self.generate_password(secret, iteration) 49 | ROTP::HOTP.new(secret).at(iteration) 50 | end 51 | 52 | def self.time_based_password(secret) 53 | ROTP::TOTP.new(secret).now 54 | end 55 | 56 | def self.valid?(code, secret, drift = DRIFT) 57 | !!ROTP::TOTP.new(secret).verify(code, drift_ahead: drift, drift_behind: drift) 58 | end 59 | 60 | def self.generate_secret 61 | ROTP::Base32.random 62 | end 63 | 64 | def self.time_until_expiration 65 | @@time_until_expiration 66 | end 67 | 68 | def self.time_until_expiration=(time_until_expiration) 69 | @@time_until_expiration = time_until_expiration 70 | end 71 | 72 | def self.cookie_key_suffix 73 | @@cookie_key_suffix 74 | end 75 | 76 | def self.cookie_key_suffix=(suffix) 77 | @@cookie_key_suffix = suffix 78 | end 79 | 80 | def self.cookie_options 81 | @@cookie_options 82 | end 83 | 84 | def self.cookie_options=(options) 85 | @@cookie_options = options 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/google-authenticator-rails/session/persistence.rb: -------------------------------------------------------------------------------- 1 | module GoogleAuthenticatorRails 2 | module Session 3 | module Persistence 4 | class TokenNotFound < StandardError; end 5 | 6 | def self.included(klass) 7 | klass.class_eval do 8 | extend ClassMethods 9 | include InstanceMethods 10 | end 11 | end 12 | end 13 | 14 | module ClassMethods 15 | def find 16 | cookie = controller.cookies[cookie_key] 17 | if cookie 18 | token, user_id = parse_cookie(cookie).values_at(:token, :user_id) 19 | conditions = { klass.google_lookup_token => token, :id => user_id } 20 | record = __send__(finder, conditions).first 21 | session = new(record) 22 | session.valid? ? session : nil 23 | else 24 | nil 25 | end 26 | end 27 | 28 | def create(user) 29 | raise GoogleAuthenticatorRails::Session::Persistence::TokenNotFound if user.nil? || !user.respond_to?(user.class.google_lookup_token) || user.google_token_value.blank? 30 | controller.cookies[cookie_key] = create_cookie(user.google_token_value, user.id) 31 | new(user) 32 | end 33 | 34 | def destroy 35 | controller.cookies.delete cookie_key 36 | end 37 | 38 | private 39 | def finder 40 | @_finder ||= klass.public_methods.include?(:where) ? :rails_3_finder : :rails_2_finder 41 | end 42 | 43 | def rails_3_finder(conditions) 44 | klass.where(conditions) 45 | end 46 | 47 | def rails_2_finder(conditions) 48 | klass.scoped(:conditions => conditions) 49 | end 50 | 51 | def klass 52 | @_klass ||= "#{self.to_s.sub("MfaSession", "")}".constantize 53 | end 54 | 55 | def parse_cookie(cookie) 56 | token, user_id = cookie.split('::') 57 | { :token => token, :user_id => user_id } 58 | end 59 | 60 | def create_cookie(token, user_id) 61 | value = [token, user_id].join('::') 62 | options = GoogleAuthenticatorRails.cookie_options || {} 63 | options.merge( 64 | :value => value, 65 | :expires => GoogleAuthenticatorRails.time_until_expiration.from_now 66 | ) 67 | end 68 | 69 | def cookie_key 70 | suffix = GoogleAuthenticatorRails.cookie_key_suffix || 'mfa_credentials' 71 | "#{klass.to_s.downcase}_#{suffix}" 72 | end 73 | end 74 | 75 | module InstanceMethods 76 | def valid? 77 | !record.nil? 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /spec/session/persistance_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe GoogleAuthenticatorRails::Session::Base do 4 | let(:controller) { MockController.new } 5 | let(:user) { User.create(:password => "password", :email => "email@example.com") } 6 | 7 | # Instantiate the controller so it activates UserSession 8 | before { controller.send(:activate_google_authenticator_rails) } 9 | 10 | describe 'ClassMethods' do 11 | describe '::find' do 12 | subject { UserMfaSession.find } 13 | 14 | context 'no session' do 15 | it { should be nil } 16 | end 17 | 18 | context 'session' do 19 | before { set_cookie_for(user) unless user.nil? } 20 | after { clear_cookie unless user.nil? } 21 | 22 | it { should be_a UserMfaSession } 23 | it "can fetch the record" do 24 | expect(subject.record).to eq(user) 25 | end 26 | 27 | context 'custom lookup token' do 28 | let(:user) { SaltUser.create(:password => "password", :email => "email@example.com") } 29 | 30 | subject { SaltUserMfaSession.find } 31 | 32 | it { should be_a SaltUserMfaSession } 33 | it "can fetch the record" do 34 | expect(subject.record).to eq(user) 35 | end 36 | end 37 | 38 | context 'after destroy' do 39 | before { UserMfaSession.destroy } 40 | 41 | subject { UserMfaSession.find } 42 | 43 | it { should be_nil } 44 | end 45 | end 46 | end 47 | 48 | describe '::create' do 49 | after { clear_cookie unless user.nil? } 50 | subject { UserMfaSession.create(user) } 51 | 52 | it { should be_a UserMfaSession } 53 | it "can fetch the record" do 54 | expect(subject.record).to eq(user) 55 | end 56 | 57 | context 'nil user' do 58 | let(:user) { nil } 59 | subject { lambda { UserMfaSession.create(user) } } 60 | it { should raise_error(GoogleAuthenticatorRails::Session::Persistence::TokenNotFound) } 61 | end 62 | end 63 | end 64 | 65 | describe 'InstanceMethods' do 66 | describe '#valid?' do 67 | subject { UserMfaSession.create(user) } 68 | context 'user object' do 69 | it { should be_valid } 70 | end 71 | end 72 | end 73 | end 74 | 75 | def set_cookie_for(user) 76 | controller.cookies[klass(user).__send__(:cookie_key)] = { :value => [user.google_token_value, user.id].join('::'), :expires => nil } 77 | end 78 | 79 | def klass(user) 80 | "#{user.class.to_s}MfaSession".constantize unless user.nil? 81 | end 82 | 83 | def clear_cookie 84 | controller.cookies[klass(user).__send__(:cookie_key)] = nil 85 | end 86 | -------------------------------------------------------------------------------- /lib/tasks/google_authenticator.rake: -------------------------------------------------------------------------------- 1 | namespace :google_authenticator do 2 | 3 | def do_encrypt(args, already_encrypted, op_name) 4 | model_names = if args[:optional_model_list] 5 | args.extras.unshift(args[:optional_model_list]) 6 | else 7 | # Adapted from https://stackoverflow.com/a/8248849/7478194 8 | Dir[Rails.root.join('app/models/*.rb').to_s].map { |filename| File.basename(filename, '.rb').camelize } 9 | end 10 | 11 | ActiveRecord::Base.transaction do 12 | match_op = already_encrypted ? " in (138,162)" : "in (16,32)" 13 | model_names.each do |model_name| 14 | klass = model_name.constantize 15 | next unless klass.ancestors.include?(ActiveRecord::Base) && klass.try(:google_secrets_encrypted) 16 | print "#{op_name}ing model #{klass.name.inspect} (table #{klass.table_name.inspect}): " 17 | count = 0 18 | klass.where("LENGTH(#{klass.google_secret_column})#{match_op}").find_each do |record| 19 | yield record 20 | count += 1 21 | end 22 | puts "#{count} #{'secret'.pluralize(count)} #{op_name}ed" 23 | end 24 | end 25 | end 26 | 27 | desc 'Encrypt all secret columns (add the :encrypt_secrets options *before* running)' 28 | task :encrypt_secrets, [:optional_model_list] => :environment do |_t, args| 29 | do_encrypt(args, false, 'Encrypt') { |record| record.encrypt_google_secret! } 30 | end 31 | 32 | desc 'Re-encrypt all secret columns from old_secret_key_base to secret_key_base' 33 | task :reencrypt_secrets, [:optional_model_list] => :environment do |_t, args| 34 | if Rails.application.secrets.old_secret_key_base.blank? 35 | puts 'old_secret_key_base is not set in config/secrets.yml' 36 | else 37 | secret_encryptor = GoogleAuthenticatorRails::ActiveRecord::Helpers.get_google_secret_encryptor 38 | Rails.application.secrets[:secret_key_base] = Rails.application.secrets.old_secret_key_base 39 | Rails.application.instance_eval { @caching_key_generator = nil } 40 | old_secret_encryptor = GoogleAuthenticatorRails::ActiveRecord::Helpers.get_google_secret_encryptor 41 | do_encrypt(args, true, 'Re-encrypt') do |record| 42 | GoogleAuthenticatorRails.secret_encryptor = old_secret_encryptor 43 | plain_secret = record.google_secret_value 44 | GoogleAuthenticatorRails.secret_encryptor = secret_encryptor 45 | record.send(:change_google_secret_to!, plain_secret) 46 | end 47 | end 48 | end 49 | 50 | desc 'Decrypt all secret columns (remove the :encrypt_secrets options *after* running)' 51 | task :decrypt_secrets, [:optional_model_list] => :environment do |_t, args| 52 | do_encrypt(args, true, 'Decrypt') { |record| record.send(:change_google_secret_to!, record.google_secret_value, false) } 53 | end 54 | 55 | end 56 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at jared.online@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /lib/google-authenticator-rails/active_record/helpers.rb: -------------------------------------------------------------------------------- 1 | module GoogleAuthenticatorRails # :nodoc: 2 | mattr_accessor :secret_encryptor 3 | 4 | module ActiveRecord # :nodoc: 5 | module Helpers 6 | 7 | # Returns and memoizes the plain text google secret for this instance, irrespective of the 8 | # name of the google secret storage column and whether secret encryption is enabled for this model. 9 | # 10 | def google_secret_value 11 | if @google_secret_value_cached 12 | @google_secret_value 13 | else 14 | @google_secret_value_cached = true 15 | secret_in_db = google_secret_column_value 16 | @google_secret_value = secret_in_db.present? && self.class.google_secrets_encrypted ? google_secret_encryptor.decrypt_and_verify(secret_in_db) : secret_in_db 17 | end 18 | end 19 | 20 | def set_google_secret 21 | change_google_secret_to!(GoogleAuthenticatorRails::generate_secret) 22 | end 23 | 24 | # Sets and saves a nil google secret value for this instance. 25 | # 26 | def clear_google_secret! 27 | change_google_secret_to!(nil) 28 | end 29 | 30 | def google_authentic?(code) 31 | raise ArgumentError, "Can't authenticate before google secret is set!" unless google_secret_value.present? 32 | 33 | GoogleAuthenticatorRails.valid?(code.to_s, google_secret_value, self.class.google_drift) 34 | end 35 | 36 | def google_qr_uri(size = nil) 37 | data = ROTP::TOTP.new(google_secret_value, :issuer => google_issuer).provisioning_uri(google_label) 38 | "https://image-charts.com/chart?cht=qr&chl=#{CGI.escape(data)}&chs=#{size || self.class.google_qr_size}" 39 | end 40 | 41 | def google_qr_to_base64(size = 200) 42 | "data:image/png;base64,#{Base64.strict_encode64(RQRCode::QRCode.new(ROTP::TOTP.new(google_secret_value, :issuer => google_issuer).provisioning_uri(google_label).to_s).as_png(size: size).to_s)}" 43 | end 44 | 45 | def google_label 46 | method = self.class.google_label_method 47 | case method 48 | when Proc 49 | method.call(self) 50 | when Symbol, String 51 | self.__send__(method) 52 | else 53 | raise NoMethodError.new("the method used to generate the google_label was never defined") 54 | end 55 | end 56 | 57 | def google_token_value 58 | self.__send__(self.class.google_lookup_token) 59 | end 60 | 61 | def encrypt_google_secret! 62 | change_google_secret_to!(google_secret_column_value) 63 | end 64 | 65 | private 66 | def default_google_label_method 67 | self.__send__(self.class.google_label_column) 68 | end 69 | 70 | def google_secret_column_value 71 | self.__send__(self.class.google_secret_column) 72 | end 73 | 74 | def change_google_secret_to!(secret, encrypt = self.class.google_secrets_encrypted) 75 | @google_secret_value = secret 76 | self.__send__("#{self.class.google_secret_column}=", secret.present? && encrypt ? google_secret_encryptor.encrypt_and_sign(secret) : secret) 77 | @google_secret_value_cached = true 78 | save! 79 | end 80 | 81 | def google_issuer 82 | issuer = self.class.google_issuer 83 | issuer.is_a?(Proc) ? issuer.call(self) : issuer 84 | end 85 | 86 | def google_secret_encryptor 87 | GoogleAuthenticatorRails.secret_encryptor ||= GoogleAuthenticatorRails::ActiveRecord::Helpers.get_google_secret_encryptor 88 | end 89 | 90 | def self.get_google_secret_encryptor 91 | encryption_key = Rails.application.key_generator.generate_key('Google-secret encryption key', 32) 92 | encryptor = ActiveSupport::MessageEncryptor.new(encryption_key) 93 | encryptor.rotate(encryption_key, cipher: 'aes-256-cbc') # Legacy support for Rails 5.2 94 | encryptor 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | ENV['RAILS_ENV'] = 'test' 2 | 3 | require 'time' 4 | require 'rails' 5 | require 'active_record' 6 | require 'action_controller' 7 | require 'rotp' 8 | require 'bundler' 9 | require 'bundler/setup' 10 | 11 | require 'simplecov' 12 | SimpleCov.start 13 | 14 | require 'google-authenticator-rails' 15 | 16 | module MockControllerBase 17 | def self.included(klass) 18 | klass.class_eval do 19 | extend ClassMethods 20 | 21 | include GoogleAuthenticatorRails::ActionController::Integration 22 | 23 | attr_accessor :cookies 24 | end 25 | end 26 | 27 | module ClassMethods 28 | attr_accessor :callbacks 29 | 30 | def prepend_before_filter(filter) 31 | self.callbacks ||= [] 32 | self.callbacks = [filter] + self.callbacks 33 | end 34 | end 35 | 36 | def initialize 37 | @cookies = MockCookieJar.new 38 | end 39 | end 40 | 41 | class MockController 42 | include MockControllerBase 43 | end 44 | 45 | class MockControllerWithApplicationController 46 | include MockControllerBase 47 | end 48 | 49 | # Simulate Rails' autoloading for ApplicationController. 50 | autoload :ApplicationController, File.join(File.dirname(__FILE__), 'support/application_controller') 51 | 52 | class MockCookieJar < Hash 53 | def [](key) 54 | hash = super 55 | hash && hash[:value] 56 | end 57 | 58 | def cookie_domain 59 | nil 60 | end 61 | 62 | def delete(key, options = {}) 63 | super(key) 64 | end 65 | end 66 | 67 | class UserMfaSession < GoogleAuthenticatorRails::Session::Base; end 68 | 69 | ActiveRecord::Base.establish_connection( 70 | :adapter => 'sqlite3', 71 | :database => ':memory:' 72 | ) 73 | 74 | ActiveRecord::Schema.define do 75 | self.verbose = false 76 | 77 | create_table :users, :force => true do |t| 78 | t.string :google_secret 79 | t.string :email 80 | t.string :user_name 81 | t.string :password 82 | t.string :persistence_token 83 | t.string :salt 84 | 85 | t.timestamps 86 | end 87 | 88 | create_table :custom_users, :force => true do |t| 89 | t.string :mfa_secret 90 | t.string :email 91 | t.string :user_name 92 | t.string :persistence_token 93 | t.string :salt 94 | 95 | t.timestamps 96 | end 97 | end 98 | 99 | class BaseUser < ActiveRecord::Base 100 | # Older versions of ActiveRecord allow attr_accessible, but newer 101 | # ones do not 102 | begin 103 | attr_accessible :email, :user_name, :password 104 | rescue 105 | attr_accessor :email, :user_name, :password 106 | end 107 | 108 | self.table_name = "users" 109 | 110 | before_save do |user| 111 | user.persistence_token ||= "token" 112 | user.salt ||= "salt" 113 | end 114 | end 115 | 116 | class User < BaseUser 117 | acts_as_google_authenticated 118 | end 119 | 120 | class CustomUser < BaseUser 121 | self.table_name = "custom_users" 122 | acts_as_google_authenticated :google_secret_column => :mfa_secret 123 | end 124 | 125 | class NilMethodUser < BaseUser 126 | acts_as_google_authenticated :method => true 127 | end 128 | 129 | class ColumnNameUser < BaseUser 130 | acts_as_google_authenticated :column_name => :user_name 131 | end 132 | 133 | class DriftUser < BaseUser 134 | acts_as_google_authenticated :drift => 31 135 | end 136 | 137 | class ProcLabelUser < BaseUser 138 | acts_as_google_authenticated :method => Proc.new { |user| "#{user.user_name}@futureadvisor-admin" } 139 | end 140 | 141 | class ProcIssuerUser < BaseUser 142 | acts_as_google_authenticated :issuer => Proc.new { |user| user.admin? ? "FA Admin" : "FutureAdvisor" } 143 | def admin? 144 | true 145 | end 146 | end 147 | 148 | class SymbolUser < BaseUser 149 | acts_as_google_authenticated :method => :email 150 | end 151 | 152 | class StringUser < BaseUser 153 | acts_as_google_authenticated :method => "email" 154 | end 155 | 156 | class SaltUserMfaSession < GoogleAuthenticatorRails::Session::Base; end 157 | 158 | class SaltUser < BaseUser 159 | acts_as_google_authenticated :lookup_token => :salt 160 | end 161 | 162 | class QrCodeUser < BaseUser 163 | acts_as_google_authenticated :qr_size => '300x300', :method => :email 164 | end 165 | 166 | class EncryptedUser < BaseUser 167 | acts_as_google_authenticated :encrypt_secrets => true 168 | end 169 | 170 | class EncryptedCustomUser < BaseUser 171 | self.table_name = 'custom_users' 172 | acts_as_google_authenticated :encrypt_secrets => true, :google_secret_column => :mfa_secret 173 | end 174 | 175 | class UserFactory 176 | def self.create(klass) 177 | klass.create(:email => 'test@example.com', :user_name => 'test_user') 178 | end 179 | end 180 | 181 | class RailsApplication < Rails::Application 182 | end if defined?(Rails) 183 | -------------------------------------------------------------------------------- /lib/google-authenticator-rails/active_record/acts_as_google_authenticated.rb: -------------------------------------------------------------------------------- 1 | module GoogleAuthenticatorRails # :nodoc: 2 | module ActiveRecord # :nodoc: 3 | module ActsAsGoogleAuthenticated # :nodoc: 4 | def self.included(base) 5 | base.extend ClassMethods 6 | end 7 | 8 | # This is the single integration point. Monkey patch ActiveRecord::Base 9 | # to include the ActsAsGoogleAuthenticated module, which allows a user 10 | # to call User.acts_as_google_authenticated. 11 | # 12 | # The model being used must have a string column named "google_secret", or an explicitly 13 | # named column. 14 | # 15 | # Example: 16 | # 17 | # class User 18 | # acts_as_google_authenticated 19 | # end 20 | # 21 | # @user = user.new 22 | # @user.set_google_secret # => true 23 | # @user.google_secret_value # => 16-character decrypted secret 24 | # @user.google_qr_uri # => http://path.to.chart/qr?with=params 25 | # @user.google_authentic?(123456) # => true 26 | # 27 | # Google Labels 28 | # When setting up an account with the GoogleAuthenticator you need to provide 29 | # a label for that account (to distinguish it from other accounts). 30 | # 31 | # GoogleAuthenticatorRails allows you to customize how the record will create 32 | # that label. There are three options: 33 | # - The default just uses the column "email" on the model 34 | # - You can specify a custom column with the :column_name option 35 | # - You can specify a custom method via a symbol or a proc 36 | # 37 | # Examples: 38 | # 39 | # class User 40 | # acts_as_google_authenticated :column => :user_name 41 | # end 42 | # 43 | # @user = User.new(:user_name => "ted") 44 | # @user.google_label # => "ted" 45 | # 46 | # class User 47 | # acts_as_google_authenticated :method => :user_name_with_label 48 | # 49 | # def user_name_with_label 50 | # "#{user_name}@example.com" 51 | # end 52 | # end 53 | # 54 | # @user = User.new(:user_name => "ted") 55 | # @user.google_label # => "ted@example.com" 56 | # 57 | # class User 58 | # acts_as_google_authenticated :method => Proc.new { |user| user.user_name_with_label.upcase } 59 | # 60 | # def user_name_with_label 61 | # "#{user_name}@example.com" 62 | # end 63 | # end 64 | # 65 | # @user = User.new(:user_name => "ted") 66 | # @user.google_label # => "TED@EXAMPLE.COM" 67 | # 68 | module ClassMethods # :nodoc 69 | 70 | # Initializes the class attributes with the specified options and includes the 71 | # respective ActiveRecord helper methods 72 | # 73 | # Options: 74 | # [:column_name] the name of the column used to create the google_label 75 | # [:method] name of the method to call to create the google_label 76 | # it supercedes :column_name 77 | # [:google_secret_column] the column the secret will be stored in, defaults 78 | # to "google_secret" 79 | # [:lookup_token] the column to use to find the record from the DB, defaults 80 | # to "persistence_token" 81 | # [:drift] drift the number of seconds that the client and server are 82 | # allowed to drift apart. Default value is 6. 83 | # 84 | # [:issuer] the name of the issuer to appear in the app (optional), defaults 85 | # to "" 86 | # [:qr_size] the size of the QR code generated by `google_qr_uri`, defaults 87 | # to "200x200" 88 | # If [:encrypt_secrets] is true, secrets will be encrypted with a key derived from 89 | # `secret_key_base`. 90 | # 91 | def acts_as_google_authenticated(options = {}) 92 | @google_label_column = options[:column_name] || :email 93 | @google_label_method = options[:method] || :default_google_label_method 94 | @google_secret_column = options[:google_secret_column] || :google_secret 95 | @google_lookup_token = options[:lookup_token] || :persistence_token 96 | @google_drift = options[:drift] || GoogleAuthenticatorRails::DRIFT 97 | @google_issuer = options[:issuer] 98 | @google_qr_size = options[:qr_size] || '200x200' 99 | @google_secrets_encrypted = !!options[:encrypt_secrets] 100 | 101 | if @google_secrets_encrypted && !GoogleAuthenticatorRails.encryption_supported? 102 | msg = "Google secret encryption is only supported on Ruby on Rails 4.1 and above. Encryption has been disabled for #{name}." 103 | if defined?(Rails) && !Rails.env.test? 104 | Rails.logger.warn msg 105 | else 106 | puts msg 107 | end 108 | @google_secrets_encrypted = false 109 | end 110 | 111 | puts ":skip_attr_accessible is no longer required. Called from #{Kernel.caller[0]}}" if options.has_key?(:skip_attr_accessible) 112 | 113 | [:google_label_column, :google_label_method, :google_secret_column, :google_lookup_token, :google_drift, :google_issuer, :google_qr_size, :google_secrets_encrypted].each do |cattr| 114 | self.singleton_class.class_eval { attr_reader cattr } 115 | end 116 | 117 | include GoogleAuthenticatorRails::ActiveRecord::Helpers 118 | end 119 | end 120 | end 121 | end 122 | end 123 | 124 | ActiveRecord::Base.send(:include, GoogleAuthenticatorRails::ActiveRecord::ActsAsGoogleAuthenticated) 125 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | google-authenticator-rails (3.4.3) 5 | actionpack 6 | activerecord 7 | rails 8 | rotp (>= 5.0, < 7.0) 9 | rqrcode 10 | 11 | GEM 12 | remote: https://rubygems.org/ 13 | specs: 14 | actioncable (7.1.5.1) 15 | actionpack (= 7.1.5.1) 16 | activesupport (= 7.1.5.1) 17 | nio4r (~> 2.0) 18 | websocket-driver (>= 0.6.1) 19 | zeitwerk (~> 2.6) 20 | actionmailbox (7.1.5.1) 21 | actionpack (= 7.1.5.1) 22 | activejob (= 7.1.5.1) 23 | activerecord (= 7.1.5.1) 24 | activestorage (= 7.1.5.1) 25 | activesupport (= 7.1.5.1) 26 | mail (>= 2.7.1) 27 | net-imap 28 | net-pop 29 | net-smtp 30 | actionmailer (7.1.5.1) 31 | actionpack (= 7.1.5.1) 32 | actionview (= 7.1.5.1) 33 | activejob (= 7.1.5.1) 34 | activesupport (= 7.1.5.1) 35 | mail (~> 2.5, >= 2.5.4) 36 | net-imap 37 | net-pop 38 | net-smtp 39 | rails-dom-testing (~> 2.2) 40 | actionpack (7.1.5.1) 41 | actionview (= 7.1.5.1) 42 | activesupport (= 7.1.5.1) 43 | nokogiri (>= 1.8.5) 44 | racc 45 | rack (>= 2.2.4) 46 | rack-session (>= 1.0.1) 47 | rack-test (>= 0.6.3) 48 | rails-dom-testing (~> 2.2) 49 | rails-html-sanitizer (~> 1.6) 50 | actiontext (7.1.5.1) 51 | actionpack (= 7.1.5.1) 52 | activerecord (= 7.1.5.1) 53 | activestorage (= 7.1.5.1) 54 | activesupport (= 7.1.5.1) 55 | globalid (>= 0.6.0) 56 | nokogiri (>= 1.8.5) 57 | actionview (7.1.5.1) 58 | activesupport (= 7.1.5.1) 59 | builder (~> 3.1) 60 | erubi (~> 1.11) 61 | rails-dom-testing (~> 2.2) 62 | rails-html-sanitizer (~> 1.6) 63 | activejob (7.1.5.1) 64 | activesupport (= 7.1.5.1) 65 | globalid (>= 0.3.6) 66 | activemodel (7.1.5.1) 67 | activesupport (= 7.1.5.1) 68 | activerecord (7.1.5.1) 69 | activemodel (= 7.1.5.1) 70 | activesupport (= 7.1.5.1) 71 | timeout (>= 0.4.0) 72 | activestorage (7.1.5.1) 73 | actionpack (= 7.1.5.1) 74 | activejob (= 7.1.5.1) 75 | activerecord (= 7.1.5.1) 76 | activesupport (= 7.1.5.1) 77 | marcel (~> 1.0) 78 | activesupport (7.1.5.1) 79 | base64 80 | benchmark (>= 0.3) 81 | bigdecimal 82 | concurrent-ruby (~> 1.0, >= 1.0.2) 83 | connection_pool (>= 2.2.5) 84 | drb 85 | i18n (>= 1.6, < 2) 86 | logger (>= 1.4.2) 87 | minitest (>= 5.1) 88 | mutex_m 89 | securerandom (>= 0.3) 90 | tzinfo (~> 2.0) 91 | appraisal (2.5.0) 92 | bundler 93 | rake 94 | thor (>= 0.14.0) 95 | base64 (0.2.0) 96 | benchmark (0.4.0) 97 | bigdecimal (3.1.9) 98 | builder (3.3.0) 99 | chunky_png (1.4.0) 100 | concurrent-ruby (1.3.5) 101 | connection_pool (2.5.0) 102 | crass (1.0.6) 103 | date (3.4.1) 104 | diff-lcs (1.5.1) 105 | docile (1.4.0) 106 | drb (2.2.1) 107 | erubi (1.13.1) 108 | globalid (1.2.1) 109 | activesupport (>= 6.1) 110 | i18n (1.14.7) 111 | concurrent-ruby (~> 1.0) 112 | io-console (0.7.2) 113 | irb (1.13.1) 114 | rdoc (>= 4.0.0) 115 | reline (>= 0.4.2) 116 | logger (1.6.6) 117 | loofah (2.24.0) 118 | crass (~> 1.0.2) 119 | nokogiri (>= 1.12.0) 120 | mail (2.8.1) 121 | mini_mime (>= 0.1.1) 122 | net-imap 123 | net-pop 124 | net-smtp 125 | marcel (1.0.4) 126 | mini_mime (1.1.5) 127 | minitest (5.25.4) 128 | mutex_m (0.3.0) 129 | net-imap (0.4.19) 130 | date 131 | net-protocol 132 | net-pop (0.1.2) 133 | net-protocol 134 | net-protocol (0.2.2) 135 | timeout 136 | net-smtp (0.5.0) 137 | net-protocol 138 | nio4r (2.7.3) 139 | nokogiri (1.18.3-arm64-darwin) 140 | racc (~> 1.4) 141 | nokogiri (1.18.3-x86_64-linux-gnu) 142 | racc (~> 1.4) 143 | psych (5.1.2) 144 | stringio 145 | racc (1.8.1) 146 | rack (3.1.12) 147 | rack-session (2.1.0) 148 | base64 (>= 0.1.0) 149 | rack (>= 3.0.0) 150 | rack-test (2.2.0) 151 | rack (>= 1.3) 152 | rackup (2.1.0) 153 | rack (>= 3) 154 | webrick (~> 1.8) 155 | rails (7.1.5.1) 156 | actioncable (= 7.1.5.1) 157 | actionmailbox (= 7.1.5.1) 158 | actionmailer (= 7.1.5.1) 159 | actionpack (= 7.1.5.1) 160 | actiontext (= 7.1.5.1) 161 | actionview (= 7.1.5.1) 162 | activejob (= 7.1.5.1) 163 | activemodel (= 7.1.5.1) 164 | activerecord (= 7.1.5.1) 165 | activestorage (= 7.1.5.1) 166 | activesupport (= 7.1.5.1) 167 | bundler (>= 1.15.0) 168 | railties (= 7.1.5.1) 169 | rails-dom-testing (2.2.0) 170 | activesupport (>= 5.0.0) 171 | minitest 172 | nokogiri (>= 1.6) 173 | rails-html-sanitizer (1.6.2) 174 | loofah (~> 2.21) 175 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) 176 | railties (7.1.5.1) 177 | actionpack (= 7.1.5.1) 178 | activesupport (= 7.1.5.1) 179 | irb 180 | rackup (>= 1.0.0) 181 | rake (>= 12.2) 182 | thor (~> 1.0, >= 1.2.2) 183 | zeitwerk (~> 2.6) 184 | rake (13.2.1) 185 | rdoc (6.7.0) 186 | psych (>= 4.0.0) 187 | reline (0.5.8) 188 | io-console (~> 0.5) 189 | rotp (6.3.0) 190 | rqrcode (2.2.0) 191 | chunky_png (~> 1.0) 192 | rqrcode_core (~> 1.0) 193 | rqrcode_core (1.2.0) 194 | rspec (3.4.0) 195 | rspec-core (~> 3.4.0) 196 | rspec-expectations (~> 3.4.0) 197 | rspec-mocks (~> 3.4.0) 198 | rspec-core (3.4.4) 199 | rspec-support (~> 3.4.0) 200 | rspec-expectations (3.4.0) 201 | diff-lcs (>= 1.2.0, < 2.0) 202 | rspec-support (~> 3.4.0) 203 | rspec-mocks (3.4.1) 204 | diff-lcs (>= 1.2.0, < 2.0) 205 | rspec-support (~> 3.4.0) 206 | rspec-support (3.4.1) 207 | securerandom (0.4.1) 208 | simplecov (0.22.0) 209 | docile (~> 1.1) 210 | simplecov-html (~> 0.11) 211 | simplecov_json_formatter (~> 0.1) 212 | simplecov-html (0.12.3) 213 | simplecov_json_formatter (0.1.4) 214 | sqlite3 (1.7.3-arm64-darwin) 215 | sqlite3 (1.7.3-x86_64-linux) 216 | stringio (3.1.0) 217 | thor (1.3.1) 218 | timeout (0.4.3) 219 | tzinfo (2.0.6) 220 | concurrent-ruby (~> 1.0) 221 | webrick (1.8.2) 222 | websocket-driver (0.7.6) 223 | websocket-extensions (>= 0.1.0) 224 | websocket-extensions (0.1.5) 225 | zeitwerk (2.6.15) 226 | 227 | PLATFORMS 228 | arm64-darwin-22 229 | x86_64-linux 230 | 231 | DEPENDENCIES 232 | appraisal (~> 2.5.0) 233 | google-authenticator-rails! 234 | rake (~> 13.0) 235 | rspec (~> 3.4.0) 236 | simplecov 237 | sqlite3 (~> 1.7) 238 | 239 | BUNDLED WITH 240 | 2.4.10 241 | -------------------------------------------------------------------------------- /spec/google_authenticator_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe GoogleAuthenticatorRails do 4 | def get_secret 5 | 'LMFMFDV3UGN7LBO4FEMVQVJOK7F7FLLE' 6 | end 7 | 8 | describe '#generate_password' do 9 | subject { GoogleAuthenticatorRails::generate_password(get_secret, counter) } 10 | 11 | context 'counter = 1' do 12 | let(:counter) { 1 } 13 | it { should == "664045" } 14 | end 15 | 16 | context 'counter = 2' do 17 | let(:counter) { 2 } 18 | it { should == "548382" } 19 | end 20 | end 21 | 22 | context 'time-based passwords' do 23 | let(:secret) { get_secret } 24 | let(:original_time) { Time.parse("2012-08-07 11:11:00 AM +0700") } 25 | let!(:time) { original_time } 26 | let(:code) { "954578" } 27 | 28 | before do 29 | allow(Time).to receive(:now).and_return(time) 30 | allow(ROTP::Base32).to receive(:random).and_return(secret) 31 | end 32 | 33 | specify { GoogleAuthenticatorRails::time_based_password(secret).should == code } 34 | specify { GoogleAuthenticatorRails::valid?(code, secret).should be true } 35 | 36 | specify { GoogleAuthenticatorRails::valid?(code * 2, secret).should be false } 37 | specify { GoogleAuthenticatorRails::valid?(code, secret * 2).should be false } 38 | 39 | it 'can create a secret' do 40 | GoogleAuthenticatorRails::generate_secret.should == secret 41 | end 42 | 43 | context 'integration with ActiveRecord' do 44 | let(:user) { UserFactory.create User } 45 | 46 | before do 47 | @user = user 48 | user.google_secret = secret 49 | end 50 | 51 | context "custom drift" do 52 | # 30 seconds drift 53 | let(:user) { UserFactory.create DriftUser } 54 | subject { user.google_authentic?(code) } 55 | 56 | context '6 seconds of drift' do 57 | let(:time) { original_time + 36.seconds } 58 | it { should be true } 59 | end 60 | 61 | context '30 seconds of drift' do 62 | let(:time) { original_time + 61.seconds } 63 | it { should be false } 64 | end 65 | end 66 | 67 | context 'code validation' do 68 | subject { user.google_authentic?(code) } 69 | 70 | it { should be true } 71 | 72 | context 'within 5 seconds of drift' do 73 | let(:time) { original_time + 34.seconds } 74 | it { should be true } 75 | end 76 | 77 | context '6 seconds of drift' do 78 | let(:time) { original_time + 36.seconds } 79 | it { should be false } 80 | end 81 | end 82 | 83 | context 'integer code validation' do 84 | subject { user.google_authentic?(code.to_i) } 85 | 86 | it { should be true } 87 | end 88 | 89 | it 'creates a secret' do 90 | user.set_google_secret 91 | user.google_secret.should == secret 92 | end 93 | 94 | shared_examples 'handles nil secrets' do 95 | it 'clears a secret' do 96 | @user.clear_google_secret! 97 | @user.google_secret_value.should(be_nil) && @user.reload.google_secret_value.should(be_nil) 98 | end 99 | 100 | it 'raises exception when checking authenticity' do 101 | @user.clear_google_secret! 102 | 103 | expect { @user.google_authentic?(code) }.to raise_error(ArgumentError) 104 | end 105 | end 106 | 107 | it_behaves_like 'handles nil secrets' 108 | 109 | context 'encrypted column' do 110 | before do 111 | @user = UserFactory.create EncryptedUser 112 | @user.set_google_secret 113 | end 114 | 115 | it 'encrypts_the_secret' do 116 | @user.google_secret.length.should == (GoogleAuthenticatorRails.encryption_supported? ? 162 : 32) 117 | end 118 | 119 | it 'decrypts_the_secret' do 120 | @user.google_secret_value.should == secret 121 | end 122 | 123 | it 'validates code' do 124 | @user.google_authentic?(code).should be_truthy 125 | end 126 | 127 | it_behaves_like 'handles nil secrets' 128 | end 129 | 130 | context 'custom secret column' do 131 | before do 132 | @user = UserFactory.create CustomUser 133 | @user.set_google_secret 134 | end 135 | 136 | it 'validates code' do 137 | @user.google_authentic?(code).should be_truthy 138 | end 139 | 140 | it 'generates a url for a qr code' do 141 | @user.google_qr_uri.should == "https://image-charts.com/chart?cht=qr&chl=otpauth%3A%2F%2Ftotp%2Ftest%2540example.com%3Fsecret%3D#{secret}&chs=200x200" 142 | end 143 | end 144 | 145 | context 'encrypted column with custom secret column' do 146 | before do 147 | @user = UserFactory.create EncryptedCustomUser 148 | @user.set_google_secret 149 | end 150 | 151 | it 'encrypts the secret' do 152 | @user.mfa_secret.length.should == (GoogleAuthenticatorRails.encryption_supported? ? 162 : 32) 153 | end 154 | 155 | it 'decrypts the secret' do 156 | @user.google_secret_value.should == secret 157 | end 158 | 159 | it 'validates code' do 160 | @user.google_authentic?(code).should be_truthy 161 | end 162 | end 163 | 164 | if GoogleAuthenticatorRails.encryption_supported? 165 | context 'encryption Rake tasks' do 166 | before(:all) { Rails.application.load_tasks } 167 | 168 | def set_and_run_task(type) 169 | User.delete_all 170 | EncryptedCustomUser.delete_all 171 | @user = UserFactory.create User 172 | @user.set_google_secret 173 | @encrypted_user = UserFactory.create EncryptedCustomUser 174 | @encrypted_user.set_google_secret 175 | @non_encrypted_user = UserFactory.create EncryptedCustomUser 176 | @non_encrypted_user.update_attribute(:mfa_secret, get_secret) 177 | Rake.application.invoke_task("google_authenticator:#{type}_secrets[User,EncryptedCustomUser]") 178 | end 179 | 180 | def encryption_ok?(user, secret_should_be_encrypted) 181 | secret_value = user.reload.send(:google_secret_column_value) 182 | (secret_value.blank? || secret_value.length.should == (secret_should_be_encrypted ? 162 : 32)) && 183 | (user.class.google_secrets_encrypted ^ secret_should_be_encrypted || user.google_secret_value == secret) 184 | end 185 | 186 | shared_examples 'task tests' do |type| 187 | it 'handles non-encrypted secrets' do 188 | encryption_ok?(@non_encrypted_user, type == 'encrypt') 189 | end 190 | 191 | it 'handles encrypted secrets' do 192 | encryption_ok?(@encrypted_user, type != 'decrypt') 193 | end 194 | 195 | it "doesn't #{type} non-encrypted models" do 196 | encryption_ok?(@user, false) 197 | end 198 | end 199 | 200 | context 'encrypt_secrets task' do 201 | before(:all) { set_and_run_task('encrypt') } 202 | it_behaves_like 'task tests', 'encrypt' 203 | end 204 | 205 | context 'decrypt_secrets task' do 206 | before(:all) { set_and_run_task('decrypt') } 207 | it_behaves_like 'task tests', 'decrypt' 208 | end 209 | 210 | context 'reencrypt_secrets task' do 211 | before(:all) do 212 | def reset_encryption(secret_key_base) 213 | Rails.application.secrets[:secret_key_base] = secret_key_base 214 | Rails.application.instance_eval { @caching_key_generator = nil } 215 | GoogleAuthenticatorRails.secret_encryptor = nil 216 | end 217 | 218 | current_secret_key_base = Rails.application.secrets[:secret_key_base] 219 | reset_encryption(Rails.application.secrets.old_secret_key_base) 220 | set_and_run_task('reencrypt') 221 | reset_encryption(current_secret_key_base) 222 | end 223 | 224 | it_behaves_like 'task tests', 'reencrypt' 225 | end 226 | end 227 | end 228 | 229 | context 'google label' do 230 | let(:user) { UserFactory.create NilMethodUser } 231 | subject { lambda { user.google_label } } 232 | it { should raise_error(NoMethodError) } 233 | end 234 | 235 | context "drift value" do 236 | it { DriftUser.google_drift.should == 31 } 237 | 238 | context "default value" do 239 | it { User.google_drift.should == 6 } 240 | end 241 | end 242 | 243 | context 'qr codes' do 244 | let(:user) { UserFactory.create User } 245 | before { user.set_google_secret } 246 | subject { user.google_qr_uri } 247 | 248 | it { should eq "https://image-charts.com/chart?cht=qr&chl=otpauth%3A%2F%2Ftotp%2Ftest%2540example.com%3Fsecret%3D#{secret}&chs=200x200" } 249 | 250 | context 'custom column name' do 251 | let(:user) { UserFactory.create ColumnNameUser } 252 | it { should eq "https://image-charts.com/chart?cht=qr&chl=otpauth%3A%2F%2Ftotp%2Ftest_user%3Fsecret%3D#{secret}&chs=200x200" } 253 | end 254 | 255 | context 'custom proc' do 256 | let(:user) { UserFactory.create ProcLabelUser } 257 | it { should eq "https://image-charts.com/chart?cht=qr&chl=otpauth%3A%2F%2Ftotp%2Ftest_user%2540futureadvisor-admin%3Fsecret%3D#{secret}&chs=200x200" } 258 | end 259 | 260 | context 'custom issuer' do 261 | let(:user) { UserFactory.create ProcIssuerUser } 262 | it { should eq "https://image-charts.com/chart?cht=qr&chl=otpauth%3A%2F%2Ftotp%2FFA%2520Admin%3Atest%2540example.com%3Fsecret%3D#{secret}%26issuer%3DFA%2520Admin&chs=200x200" } 263 | end 264 | 265 | context 'method defined by symbol' do 266 | let(:user) { UserFactory.create SymbolUser } 267 | it { should eq "https://image-charts.com/chart?cht=qr&chl=otpauth%3A%2F%2Ftotp%2Ftest%2540example.com%3Fsecret%3D#{secret}&chs=200x200" } 268 | end 269 | 270 | context 'method defined by string' do 271 | let(:user) { UserFactory.create StringUser } 272 | it { should eq "https://image-charts.com/chart?cht=qr&chl=otpauth%3A%2F%2Ftotp%2Ftest%2540example.com%3Fsecret%3D#{secret}&chs=200x200" } 273 | end 274 | 275 | context 'custom qr size' do 276 | let(:user) { UserFactory.create QrCodeUser } 277 | it { should eq "https://image-charts.com/chart?cht=qr&chl=otpauth%3A%2F%2Ftotp%2Ftest%2540example.com%3Fsecret%3D#{secret}&chs=300x300" } 278 | end 279 | 280 | context 'qr size passed to method' do 281 | subject { user.google_qr_uri('400x400') } 282 | let(:user) { UserFactory.create StringUser } 283 | it { should eq "https://image-charts.com/chart?cht=qr&chl=otpauth%3A%2F%2Ftotp%2Ftest%2540example.com%3Fsecret%3D#{secret}&chs=400x400" } 284 | end 285 | 286 | context 'qr size passed to method and size set on model' do 287 | let(:user) { UserFactory.create QrCodeUser } 288 | subject { user.google_qr_uri('400x400') } 289 | it { should eq "https://image-charts.com/chart?cht=qr&chl=otpauth%3A%2F%2Ftotp%2Ftest%2540example.com%3Fsecret%3D#{secret}&chs=400x400" } 290 | end 291 | 292 | context 'generates base64 image' do 293 | let(:user) { UserFactory.create QrCodeUser } 294 | it { user.google_qr_to_base64.include?('data:image/png;base64').should be_truthy } 295 | end 296 | end 297 | end 298 | end 299 | end 300 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GoogleAuthenticatorRails 2 | 3 | [![Gem Version](https://badge.fury.io/rb/google-authenticator-rails.png)](http://badge.fury.io/rb/google-authenticator-rails) 4 | [![Code Climate](https://codeclimate.com/github/jaredonline/google-authenticator.png)](https://codeclimate.com/github/jaredonline/google-authenticator) 5 | [![Test Coverage](https://api.codeclimate.com/v1/badges/41e825da75bd7630262f/test_coverage)](https://codeclimate.com/github/jaredonline/google-authenticator/test_coverage) 6 | [![CircleCI](https://circleci.com/gh/jaredonline/google-authenticator/tree/master.svg?style=svg)](https://circleci.com/gh/jaredonline/google-authenticator/tree/master) 7 | 8 | Rails (ActiveRecord) integration with the Google Authenticator apps for [Android](https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2) and the [iPhone](https://itunes.apple.com/us/app/google-authenticator/id388497605?mt=8). Uses the Authlogic style for cookie management. 9 | 10 | ## Installation 11 | 12 | Add this line to your application's Gemfile: 13 | 14 | gem 'google-authenticator-rails' 15 | 16 | And then execute: 17 | 18 | $ bundle 19 | 20 | Or install it yourself as: 21 | 22 | $ gem install google-authenticator-rails 23 | 24 | ## Usage 25 | 26 | Example: 27 | 28 | ```ruby 29 | class User 30 | acts_as_google_authenticated 31 | end 32 | 33 | @user = User.new 34 | @user.set_google_secret # => true 35 | @user.google_secret_value # => 16-character plain-text secret, whatever the name of the secret column 36 | @user.google_qr_uri # => http://path.to.chart/qr?with=params 37 | @user.google_authentic?(123456) # => true 38 | @user.clear_google_secret! # => true 39 | @user.google_secret_value # => nil 40 | ``` 41 | 42 | ## Google Labels 43 | 44 | When setting up an account with `GoogleAuthenticatorRails` you need to provide a label for that account (to distinguish it from other accounts). 45 | 46 | `GoogleAuthenticatorRails` allows you to customize how the record will create that label. There are three options: 47 | - The default just uses the column `email` on the model 48 | - You can specify a custom column with the `:column_name` option 49 | - You can specify a custom method via a symbol or a proc 50 | 51 | Example: 52 | 53 | ```ruby 54 | class User 55 | acts_as_google_authenticated :column_name => :user_name 56 | end 57 | 58 | @user = User.new(:user_name => "ted") 59 | @user.google_label # => "ted" 60 | 61 | class User 62 | acts_as_google_authenticated :method => :user_name_with_label 63 | 64 | def user_name_with_label 65 | "#{user_name}@example.com" 66 | end 67 | end 68 | 69 | @user = User.new(:user_name => "ted") 70 | @user.google_label # => "ted@example.com" 71 | 72 | class User 73 | acts_as_google_authenticated :method => Proc.new { |user| user.user_name_with_label.upcase } 74 | 75 | def user_name_with_label 76 | "#{user_name}@example.com" 77 | end 78 | end 79 | 80 | @user = User.new(:user_name => "ted") 81 | @user.google_label # => "TED@EXAMPLE.COM" 82 | ``` 83 | 84 | Here's what the labels look like in Google Authenticator for iPhone: 85 | 86 | ![iPhone Label Screenshot](http://jaredonline.github.io/google-authenticator/images/gar-label.png) 87 | 88 | ## Google Secret 89 | The "google secret" is where `GoogleAuthenticatorRails` stores the 90 | secret token used to generate the MFA code. 91 | 92 | You can also specify a column for storing the google secret. The default is `google_secret`. 93 | 94 | Example 95 | 96 | ```ruby 97 | class User 98 | acts_as_google_authenticated :google_secret_column => :mfa_secret 99 | end 100 | 101 | @user = User.new 102 | @user.set_google_secret 103 | @user.mfa_secret # => "56ahi483" 104 | ``` 105 | 106 | ## Drift 107 | 108 | You can specify a custom drift value. Drift is the number of seconds that the client 109 | and server are allowed to drift apart. Default value is 5 seconds. 110 | 111 | ```ruby 112 | class User 113 | act_as_google_authenticated :drift => 31 114 | end 115 | ``` 116 | 117 | ## Lookup Token 118 | 119 | You can also specify which column the appropriate `MfaSession` subclass should use to look up the record: 120 | 121 | Example 122 | 123 | ```ruby 124 | class User 125 | acts_as_google_authenticated :lookup_token => :salt 126 | end 127 | ``` 128 | 129 | The above will cause the `UserMfaSession` class to call `User.where(:salt => cookie_salt)` or `User.scoped(:conditions => { :salt => cookie_salt })` to find the appropriate record. 130 | 131 | ### A note about record lookup 132 | 133 | `GoogleAuthenticatorRails` makes one very large assumption when attempting to lookup a record. If your `MfaSession` subclass is named `UserMfaSession` it assumes you're trying to lookup a `User` record. Currently, there is no way to configure this, so if you're trying to lookup a `VeryLongModelNameForUser` you'll need to name your `MfaSession` subclass `VeryLongModelNameForUserMfaSession`. 134 | 135 | For example: 136 | 137 | ```ruby 138 | # app/models/user.rb 139 | class User < ActiveRecord::Base 140 | acts_as_google_authenticated 141 | end 142 | 143 | # app/models/user_mfa_session.rb 144 | class UserMfaSession < GoogleAuthenticatorRails::Session::Base 145 | end 146 | ``` 147 | 148 | ### A note about cookie creation and `Session::Persistence::TokenNotFound` 149 | 150 | `GoogleAuthenticatorRails` looks up the record based on the cookie created when you call `MfaSession#create`. The `#create` method looks into the record class (in our example, `User`) and looks at the configured `:lookup_token` option. It uses that option to save two pieces of information into the cookie, the `id` of the record and the token, which defaults to `persistence_token`. `persistence_token` is what Authlogic uses, which this gem was originally designed to work with. 151 | 152 | This can cause a lot of headaches if the model isn't configured correctly, and will cause a `GoogleAuthenticatorRails::Session::Persistence::TokenNotFound` error. 153 | 154 | This error appears for one of three reasons: 155 | 156 | 1. `user` is `nil` 157 | 2. `user` doesn't respond to `:persistence_token` 158 | 3. `user.persistence_token` is blank 159 | 160 | For example: 161 | 162 | ```ruby 163 | # app/models/user.rb 164 | class User < ActiveRecord::Base 165 | acts_as_google_authenticated 166 | end 167 | 168 | # Model has attributes: 169 | # id: integer 170 | # name: string 171 | # salt: string 172 | 173 | # app/models/user_mfa_session.rb 174 | class UserMfaSession < GoogleAuthenticatorRails::Session::Base 175 | end 176 | 177 | # app/controllers/mfa_session_controller.rb 178 | class MfaSessionController < ApplicationController 179 | def create 180 | UserMfaSession.create(user) # => Error: GoogleAuthenticatorRails::Session::Persistence::TokenNotFound 181 | end 182 | end 183 | ``` 184 | 185 | The above example will fail because the `User` class doesn't have a `persistence_token` method. The fix for this is to configure `actions_as_google_authentic` to use the right column: 186 | 187 | ```ruby 188 | # app/models/user.rb 189 | class User < ActiveRecord::Base 190 | acts_as_google_authenticated :lookup_token => :salt 191 | end 192 | 193 | # Model has attributes: 194 | # id: integer 195 | # name: string 196 | # salt: string 197 | 198 | # app/models/user_mfa_session.rb 199 | class UserMfaSession < GoogleAuthenticatorRails::Session::Base 200 | end 201 | 202 | # app/controllers/mfa_session_controller.rb 203 | class MfaSessionController < ApplicationController 204 | def create 205 | UserMfaSession.create(user) 206 | end 207 | end 208 | ``` 209 | 210 | This call to `#create` will succeed (as long as `user.salt` is not `nil`). 211 | 212 | 213 | ## Issuer 214 | 215 | You can also specify a name for the 'issuer' (the name of the website) where the user is using this token: 216 | 217 | Example 218 | 219 | ```ruby 220 | class User 221 | acts_as_google_authenticated :issuer => 'example.com' 222 | end 223 | ``` 224 | 225 | You can also use a Proc to set a dynamic issuer for multi-tenant applications or any other custom needs: 226 | 227 | ```ruby 228 | class User 229 | acts_as_google_authenticated :issuer => Proc.new { |user| user.admin? ? "Example Admin" : "example.com" } 230 | end 231 | ``` 232 | 233 | This way your user will have the name of your site at the authenticator card besides the current token. 234 | 235 | Here's what the issuers look like in Google Authenticator for iPhone: 236 | 237 | ![iPhone Label Screenshot](http://jaredonline.github.io/google-authenticator/images/gar-issuer.png) 238 | 239 | ## Sample Rails Setup 240 | 241 | This is a very rough outline of how `GoogleAuthenticatorRails` is meant to manage the sessions and cookies for a Rails app. 242 | 243 | ```ruby 244 | # Gemfile 245 | 246 | gem 'rails' 247 | gem 'google-authenticator-rails' 248 | ``` 249 | 250 | First add a field to your user model to hold the Google token. 251 | ```ruby 252 | class AddGoogleSecretToUser < ActiveRecord::Migration 253 | def change 254 | add_column :users, :google_secret, :string 255 | end 256 | end 257 | ``` 258 | 259 | ```ruby 260 | # app/models/users.rb 261 | 262 | class User < ActiveRecord::Base 263 | acts_as_google_authenticated 264 | end 265 | ``` 266 | 267 | If you want to authenticate based on a model called `User`, then you should name your session object `UserMfaSession`. 268 | 269 | ```ruby 270 | # app/models/user_mfa_session.rb 271 | 272 | class UserMfaSession < GoogleAuthenticatorRails::Session::Base 273 | # no real code needed here 274 | end 275 | ``` 276 | 277 | ```ruby 278 | # app/controllers/user_mfa_session_controller.rb 279 | 280 | class UserMfaSessionController < ApplicationController 281 | 282 | def new 283 | # load your view 284 | end 285 | 286 | def create 287 | user = current_user # grab your currently logged in user 288 | if user.google_authentic?(params[:mfa_code]) 289 | UserMfaSession.create(user) 290 | redirect_to root_path 291 | else 292 | flash[:error] = "Wrong code" 293 | render :new 294 | end 295 | end 296 | 297 | end 298 | ``` 299 | 300 | ```ruby 301 | # app/controllers/application_controller.rb 302 | 303 | class ApplicationController < ActionController::Base 304 | before_filter :check_mfa 305 | 306 | private 307 | def check_mfa 308 | if !(user_mfa_session = UserMfaSession.find) && (user_mfa_session ? user_mfa_session.record == current_user : !user_mfa_session) 309 | redirect_to new_user_mfa_session_path 310 | end 311 | end 312 | end 313 | ``` 314 | 315 | ## Cookie options 316 | 317 | You can configure the MfaSession cookie by creating an initializer: 318 | 319 | ```ruby 320 | # config/initializers/google_authenticator_rails.rb 321 | 322 | # The cookie normally expires in 24 hours, you can change this to 1 month 323 | GoogleAuthenticatorRails.time_until_expiration = 1.month 324 | 325 | # You can override the suffix of the cookie's key, by default this is mfa_credentials 326 | GoogleAuthenticatorRails.cookie_key_suffix = 'mfa_credentials' 327 | 328 | # Rails offers a few more cookie options, by default only :httponly is turned on, you can change it to HTTPS only: 329 | GoogleAuthenticatorRails.cookie_options = { :httponly => true, :secure => true, :domain => :all } 330 | ``` 331 | 332 | Additional cookie option symbols can be found in the [Ruby on Rails guide](http://api.rubyonrails.org/classes/ActionDispatch/Cookies.html). 333 | 334 | ## Destroying the Cookie 335 | 336 | If you want to manually destroy the MFA cookie (for example, when a user logs out), just call 337 | 338 | ```ruby 339 | UserMfaSession::destroy 340 | ``` 341 | 342 | ## Storing Secrets in Encrypted Form (Rails 4.1 and above) 343 | 344 | Normally, if an attacker gets access to the application database, they will be able to generate correct authentication codes, 345 | elmininating the security gains from two-factor authentication. If the application's ```secret_key_base``` is handled more securely 346 | than the database (by, for example, never putting it on the server filesystem), protection against database compromise can 347 | be gained by setting the ```:encrypt_secrets``` option to ```true```. Newly-created secrets will then be stored in encrypted form. 348 | 349 | Existing non-encrypted secrets for all models for which the ```:encrypt_secrets``` option has been set to ```true``` 350 | can be encrypted by running 351 | ```bash 352 | rails google_authenticator:encrypt_secrets 353 | ``` 354 | This may be reversed by running 355 | ```bash 356 | rails google_authenticator:decrypt_secrets 357 | ``` 358 | then by removing, or setting ```false```, the ```:encrypt_secrets``` option. 359 | 360 | If ```secret_key_base``` needs to change, set ```old_secret_key_base``` to the old key in ```config/secrets.yml``` before generating the new key. 361 | Then run 362 | ```bash 363 | rails google_authenticator:reencrypt_secrets 364 | ``` 365 | to change all encrypted google secret fields to use the new key. 366 | 367 | If the app is not running under Rails version 4.1 or above, encryption will be disabled, and a warning issued if ```:encrypt_secrets``` 368 | is enabled on a model. 369 | 370 | If encryption is enabled for a model, the Google secret column of its table must be able to hold at least 162 characters, rather than just 32. 371 | 372 | ## Contributing 373 | 374 | 1. Fork it 375 | 2. Create your feature branch (`git checkout -b my-new-feature`) 376 | 3. Commit your changes (`git commit -am 'Added some feature'`) 377 | 4. Push to the branch (`git push origin my-new-feature`) 378 | 5. Create new Pull Request 379 | 380 | ## License 381 | 382 | MIT. 383 | 384 | --------------------------------------------------------------------------------