├── .github └── workflows │ └── active_model_otp.yml ├── .gitignore ├── Appraisals ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── active_model_otp.gemspec ├── gemfiles ├── rails_4.2.gemfile ├── rails_5.0.gemfile ├── rails_5.1.gemfile ├── rails_5.2.gemfile ├── rails_6.0.gemfile ├── rails_6.1.gemfile └── rails_7.0.gemfile ├── lib ├── active_model │ ├── one_time_password.rb │ └── otp │ │ └── version.rb └── active_model_otp.rb └── test ├── models ├── activerecord_user.rb ├── after_user.rb ├── default_interval_user.rb ├── interval_user.rb ├── member.rb ├── opt_in_two_factor.rb ├── user.rb └── visitor.rb ├── one_time_password_test.rb ├── schema.rb └── test_helper.rb /.github/workflows/active_model_otp.yml: -------------------------------------------------------------------------------- 1 | name: Active Model OTP 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | types: [opened, synchronize, reopened, edited] 8 | 9 | jobs: 10 | ci: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | gemfile: [rails_4.2, rails_5.0, rails_5.1, rails_5.2, rails_6.0, rails_6.1, rails_7.0] 16 | ruby-version: [2.3, 2.4, 2.5, 2.6, 2.7, '3.0', 3.1, 3.2] 17 | exclude: 18 | - { gemfile: rails_4.2, ruby-version: 2.5 } 19 | - { gemfile: rails_4.2, ruby-version: 2.6 } 20 | - { gemfile: rails_4.2, ruby-version: 2.7 } 21 | - { gemfile: rails_4.2, ruby-version: '3.0' } 22 | - { gemfile: rails_4.2, ruby-version: 3.1 } 23 | - { gemfile: rails_4.2, ruby-version: 3.2 } 24 | - { gemfile: rails_5.0, ruby-version: 2.5 } 25 | - { gemfile: rails_5.0, ruby-version: 2.6 } 26 | - { gemfile: rails_5.0, ruby-version: 2.7 } 27 | - { gemfile: rails_5.0, ruby-version: '3.0' } 28 | - { gemfile: rails_5.0, ruby-version: 3.1 } 29 | - { gemfile: rails_5.0, ruby-version: 3.2 } 30 | - { gemfile: rails_5.1, ruby-version: 2.6 } 31 | - { gemfile: rails_5.1, ruby-version: 2.7 } 32 | - { gemfile: rails_5.1, ruby-version: '3.0' } 33 | - { gemfile: rails_5.1, ruby-version: 3.1 } 34 | - { gemfile: rails_5.1, ruby-version: 3.2 } 35 | - { gemfile: rails_5.2, ruby-version: 2.7 } 36 | - { gemfile: rails_5.2, ruby-version: '3.0' } 37 | - { gemfile: rails_5.2, ruby-version: 3.1 } 38 | - { gemfile: rails_5.2, ruby-version: 3.2 } 39 | - { gemfile: rails_6.0, ruby-version: 2.3 } 40 | - { gemfile: rails_6.0, ruby-version: 2.4 } 41 | - { gemfile: rails_6.0, ruby-version: '3.0' } 42 | - { gemfile: rails_6.0, ruby-version: 3.1 } 43 | - { gemfile: rails_6.0, ruby-version: 3.2 } 44 | - { gemfile: rails_6.1, ruby-version: 2.3 } 45 | - { gemfile: rails_6.1, ruby-version: 2.4 } 46 | - { gemfile: rails_6.1, ruby-version: '3.0' } 47 | - { gemfile: rails_6.1, ruby-version: 3.1 } 48 | - { gemfile: rails_6.1, ruby-version: 3.2 } 49 | - { gemfile: rails_7.0, ruby-version: 2.3 } 50 | - { gemfile: rails_7.0, ruby-version: 2.4 } 51 | - { gemfile: rails_7.0, ruby-version: 2.5 } 52 | - { gemfile: rails_7.0, ruby-version: 2.6 } 53 | 54 | env: 55 | BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile 56 | 57 | steps: 58 | - uses: actions/checkout@v3 59 | 60 | - name: Install Ruby ${{ matrix.ruby-version }} 61 | uses: ruby/setup-ruby@v1 62 | with: 63 | ruby-version: ${{ matrix.ruby-version }} 64 | bundler-cache: true 65 | 66 | - name: Run tests with Ruby ${{ matrix.ruby-version }} and Gemfile ${{ matrix.gemfile }} 67 | run: bundle exec rake 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | .ruby-version 19 | gemfiles/*.lock 20 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise "rails-4.2" do 2 | gem "activemodel", "~> 4.2" 3 | gem "sqlite3", "~> 1.3.6" 4 | end 5 | 6 | appraise "rails-5.0" do 7 | gem "activemodel", "~> 5.0" 8 | gem "activemodel-serializers-xml" 9 | gem "sqlite3", "~> 1.3.6" 10 | end 11 | 12 | appraise "rails-5.1" do 13 | gem "activemodel", "~> 5.1" 14 | gem "activemodel-serializers-xml" 15 | gem "sqlite3", "~> 1.3.6" 16 | end 17 | 18 | appraise "rails-5.2" do 19 | gem "activemodel", "~> 5.2" 20 | gem "activemodel-serializers-xml" 21 | gem "sqlite3", "~> 1.3.6" 22 | end 23 | 24 | appraise "rails-6.0" do 25 | gem "activerecord", "~> 6.0" 26 | gem "activemodel", "~> 6.0" 27 | gem "activemodel-serializers-xml" 28 | gem "sqlite3", "~> 1.4" 29 | end 30 | 31 | appraise "rails-6.1" do 32 | gem "activerecord", "~> 6.1" 33 | gem "activemodel", "~> 6.1" 34 | gem "activemodel-serializers-xml" 35 | gem "sqlite3", "~> 1.4" 36 | end 37 | 38 | appraise "rails-7.0" do 39 | gem "activerecord", "~> 7.0" 40 | gem "activemodel", "~> 7.0" 41 | gem "activemodel-serializers-xml" 42 | gem "sqlite3", "~> 1.6" 43 | end 44 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG it's been deprecated in favor of https://github.com/heapsource/active_model_otp/releases 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in has_one_time_password.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-2019 Roberto Miranda, Guillermo Iguaran and contributors. 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. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Active Model OTP](https://github.com/heapsource/active_model_otp/actions/workflows/active_model_otp.yml/badge.svg?branch=main)](https://github.com/heapsource/active_model_otp/actions/workflows/active_model_otp.yml) 2 | [![Gem Version](https://badge.fury.io/rb/active_model_otp.svg)](http://badge.fury.io/rb/active_model_otp) 3 | [![Reviewed by Hound](https://img.shields.io/badge/Reviewed_by-Hound-8E64B0.svg)](https://houndci.com) 4 | 5 | 6 | # ActiveModel::Otp 7 | 8 | **ActiveModel::Otp** makes adding **Two Factor Authentication** (TFA) to a model simple. Let's see what's required to get AMo::Otp working in our Application, using Rails 5.0 (AMo::Otp is also compatible with Rails 4.x versions). We're going to use a User model and try to add options provided by **ActiveModel::Otp**. Inspired by AM::SecurePassword 9 | 10 | ## Dependencies 11 | 12 | * [ROTP](https://github.com/mdp/rotp) 6.2.0 or higher 13 | * Ruby 2.3 or greater 14 | 15 | ## Installation 16 | 17 | Add this line to your application's Gemfile: 18 | 19 | gem 'active_model_otp' 20 | 21 | And then execute: 22 | 23 | $ bundle 24 | 25 | Or install it yourself as follows: 26 | 27 | $ gem install active_model_otp 28 | 29 | ## Setting your Model 30 | 31 | We're going to add a field to our ``User`` Model, so each user can have an otp secret key. The next step is to run the migration generator in order to add the secret key field. 32 | 33 | ```ruby 34 | rails g migration AddOtpSecretKeyToUsers otp_secret_key:string 35 | => 36 | invoke active_record 37 | create db/migrate/20130707010931_add_otp_secret_key_to_users.rb 38 | ``` 39 | 40 | We’ll then need to run rake db:migrate to update the users table in the database. The next step is to update the model code. We need to use has_one_time_password to make it use TFA. 41 | 42 | ```ruby 43 | class User < ApplicationRecord 44 | has_one_time_password 45 | end 46 | ``` 47 | 48 | Note: If you're adding this to an existing user model you'll need to generate *otp_secret_key* with a migration like: 49 | ```ruby 50 | User.find_each { |user| user.update_attribute(:otp_secret_key, User.otp_random_secret) } 51 | ``` 52 | 53 | To use a custom column to store the secret key field you can use the column_name option. It is also possible to generate codes with a specified length. 54 | 55 | ```ruby 56 | class User < ApplicationRecord 57 | has_one_time_password column_name: :my_otp_secret_column, length: 4 58 | end 59 | ``` 60 | 61 | ## Usage 62 | 63 | The has_one_time_password statement provides to the model some useful methods in order to implement our TFA system. AMo:Otp generates one time passwords according to [TOTP RFC 6238](https://tools.ietf.org/html/rfc6238) and the [HOTP RFC 4226](https://www.ietf.org/rfc/rfc4226). This is compatible with Google Authenticator apps available for Android and iPhone, and now in use on GMail. 64 | 65 | The otp_secret_key is saved automatically when an object is created, 66 | 67 | ```ruby 68 | user = User.create(email: "hello@heapsource.com") 69 | user.otp_secret_key 70 | => "jt3gdd2qm6su5iqh" 71 | ``` 72 | 73 | **Note:** You can fork the applications for [iPhone](https://github.com/heapsource/google-authenticator) & [Android](https://github.com/heapsource/google-authenticator.android) and customize them 74 | 75 | ### Getting current code (e.g. to send via SMS) 76 | ```ruby 77 | user.otp_code # => '186522' 78 | sleep 30 79 | user.otp_code # => '850738' 80 | 81 | # Override current time 82 | user.otp_code(time: Time.now + 3600) # => '317438' 83 | ``` 84 | 85 | ### Authenticating using a code 86 | 87 | ```ruby 88 | user.authenticate_otp('186522') # => true 89 | sleep 30 # let's wait 30 secs 90 | user.authenticate_otp('186522') # => false 91 | ``` 92 | 93 | ### Authenticating using a slightly old code 94 | 95 | ```ruby 96 | user.authenticate_otp('186522') # => true 97 | sleep 30 # lets wait again 98 | user.authenticate_otp('186522', drift: 60) # => true 99 | ``` 100 | 101 | ### Preventing reuse of Time based OTP's 102 | 103 | By keeping track of the last time a user's OTP was verified, we can prevent token reuse during the interval window (default 30 seconds). It is useful with SMS, that is commonly used in combination with `drift` to extend the life of the code. 104 | 105 | ```ruby 106 | rails g migration AddLastOtpAtToUsers last_otp_at:integer 107 | => 108 | invoke active_record 109 | create db/migrate/20220407010931_add_last_otp_at_to_users.rb 110 | ``` 111 | 112 | ```ruby 113 | class User < ApplicationRecord 114 | has_one_time_password after_column_name: :last_otp_at 115 | end 116 | ``` 117 | 118 | ```ruby 119 | user.authenticate_otp('186522') # => true 120 | user.authenticate_otp('186522') # => false 121 | ``` 122 | 123 | ## Counter based OTP 124 | 125 | An additional counter field is required in our ``User`` Model 126 | 127 | ```ruby 128 | rails g migration AddCounterForOtpToUsers otp_counter:integer 129 | => 130 | invoke active_record 131 | create db/migrate/20130707010931_add_counter_for_otp_to_users.rb 132 | ``` 133 | 134 | Set default value for otp_counter to 0. 135 | ```ruby 136 | change_column :users, :otp_counter, :integer, default: 0 137 | ``` 138 | 139 | In addition set the counter flag option to true 140 | 141 | ```ruby 142 | class User < ApplicationRecord 143 | has_one_time_password counter_based: true 144 | end 145 | ``` 146 | 147 | And for a custom counter column 148 | 149 | ```ruby 150 | class User < ApplicationRecord 151 | has_one_time_password counter_based: true, counter_column_name: :my_otp_secret_counter_column 152 | end 153 | ``` 154 | 155 | Authentication is done the same. You can manually adjust the counter for your usage or set auto_increment on success to true. 156 | 157 | ```ruby 158 | user.authenticate_otp('186522') # => true 159 | user.authenticate_otp('186522', auto_increment: true) # => true 160 | user.authenticate_otp('186522') # => false 161 | user.otp_counter -= 1 162 | user.authenticate_otp('186522') # => true 163 | ``` 164 | 165 | When retrieving an ```otp_code``` you can also pass the ```auto_increment``` option. 166 | 167 | ```ruby 168 | user.otp_code # => '186522' 169 | user.otp_code # => '186522' 170 | user.otp_code(auto_increment: true) # => '768273' 171 | user.otp_code(auto_increment: true) # => '002811' 172 | user.otp_code # => '002811' 173 | ``` 174 | 175 | ## Backup codes 176 | 177 | We're going to add a field to our ``User`` Model, so each user can have an otp backup codes. The next step is to run the migration generator in order to add the backup codes field. 178 | 179 | ```ruby 180 | rails g migration AddOtpBackupCodesToUsers otp_backup_codes:text 181 | => 182 | invoke active_record 183 | create db/migrate/20210126030834_add_otp_backup_codes_to_users.rb 184 | ``` 185 | 186 | You can change backup codes column name by option `backup_codes_column_name`: 187 | 188 | ```ruby 189 | class User < ApplicationRecord 190 | has_one_time_password backup_codes_column_name: 'secret_codes' 191 | end 192 | ``` 193 | 194 | Then use array type in schema or serialize attribute in model as Array (depending on used db type). Or even consider to use some libs like (lockbox)[https://github.com/ankane/lockbox] with type array. 195 | 196 | After that user can use one of automatically generated backup codes for authentication using same method `authenticate_otp`. 197 | 198 | By default it generates 12 backup codes. You can change it by option `backup_codes_count`: 199 | 200 | ```ruby 201 | class User < ApplicationRecord 202 | has_one_time_password backup_codes_count: 6 203 | end 204 | ``` 205 | 206 | By default each backup code can be reused an infinite number of times. You can 207 | change it with option `one_time_backup_codes`: 208 | 209 | ```ruby 210 | class User < ApplicationRecord 211 | has_one_time_password one_time_backup_codes: true 212 | end 213 | ``` 214 | 215 | ```ruby 216 | user.authenticate_otp('186522') # => true 217 | user.authenticate_otp('186522') # => false 218 | ``` 219 | 220 | ## Google Authenticator Compatible 221 | 222 | The library works with the Google Authenticator iPhone and Android app, and also includes the ability to generate provisioning URI's to use with the QR Code scanner built into the app. 223 | 224 | ```ruby 225 | # Use your user's email address to generate the provisioning_url 226 | user.provisioning_uri # => 'otpauth://totp/hello@heapsource.com?secret=2z6hxkdwi3uvrnpn' 227 | 228 | # Use a custom field to generate the provisioning_url 229 | user.provisioning_uri("hello") # => 'otpauth://totp/hello?secret=2z6hxkdwi3uvrnpn' 230 | 231 | # You can customize the generated url, by passing a hash of Options 232 | # `:issuer` lets you set the Issuer name in Google Authenticator, so it doesn't show as a blank entry. 233 | user.provisioning_uri(nil, issuer: 'MYAPP') #=> 'otpauth://totp/hello@heapsource.com?secret=2z6hxkdwi3uvrnpn&issuer=MYAPP' 234 | ``` 235 | 236 | This can then be rendered as a QR Code which can be scanned and added to the users list of OTP credentials. 237 | 238 | ### Setting up a customer interval 239 | 240 | If you define a custom interval for TOTP codes, just as `has_one_time_password interval: 10` (for example), remember to include the interval also in `provisioning_uri` method. If not defined, the default value is 30 seconds (according to ROTP gem: https://github.com/mdp/rotp/blob/master/lib/rotp/totp.rb#L9) 241 | 242 | ```ruby 243 | class User < ApplicationRecord 244 | has_one_time_password interval: 10 # the interval value is in seconds 245 | end 246 | 247 | user = User.new 248 | user.provisioning_uri("hello", interval: 10) # => 'otpauth://totp/hello?secret=2z6hxkdwi3uvrnpn&period=10' 249 | 250 | # This code snippet generates OTP codes that expires every 10 seconds. 251 | ``` 252 | 253 | **Note**: Only some authenticator apps are compatible with custom `period` of tokens, for more details check these links: 254 | 255 | - https://labanskoller.se/blog/2019/07/11/many-common-mobile-authenticator-apps-accept-qr-codes-for-modes-they-dont-support 256 | - https://www.ibm.com/docs/en/sva/9.0.7?topic=authentication-configuring-totp-one-time-password-mechanism 257 | 258 | So, be careful and aware when using custom intervals/periods for your TOTP codes beyond the default 30 seconds :) 259 | 260 | ### Working example 261 | 262 | Scan the following barcode with your phone, using Google Authenticator 263 | 264 | ![QRCODE](http://qrfree.kaywa.com/?l=1&s=8&d=otpauth%3A%2F%2Ftotp%2Froberto%40heapsource.com%3Fsecret%3D2z6hxkdwi3uvrnpn) 265 | 266 | Now run the following and compare the output 267 | 268 | ```ruby 269 | require "active_model_otp" 270 | 271 | class User 272 | extend ActiveModel::Callbacks 273 | include ActiveModel::Validations 274 | include ActiveModel::OneTimePassword 275 | 276 | define_model_callbacks :create 277 | attr_accessor :otp_secret_key, :email 278 | 279 | has_one_time_password 280 | end 281 | 282 | user = User.new 283 | user.email = 'roberto@heapsource.com' 284 | user.otp_secret_key = "2z6hxkdwi3uvrnpn" 285 | puts "Current code #{user.otp_code}" 286 | ``` 287 | 288 | **Note:** otp_secret_key must be generated using RFC 3548 base32 key strings (for compatilibity with google authenticator) 289 | 290 | ### Useful Examples 291 | - [Drifting Ruby Tutorial](https://www.driftingruby.com/episodes/two-factor-authentication) 292 | - [Generate QR code with rqrcode gem](https://github.com/heapsource/active_model_otp/wiki/Generate-QR-code-with-rqrcode-gem) 293 | - Generating QR Code with Google Charts API 294 | - [Sending code via SMS with Twilio](https://github.com/heapsource/active_model_otp/wiki/Send-code-via-Twilio-SMS) 295 | - [Using with Mongoid](https://github.com/heapsource/active_model_otp/wiki/Using-with-Mongoid) 296 | 297 | ## Contributing 298 | 299 | 1. Fork it 300 | 2. Create your feature branch (`git checkout -b my-new-feature`) 301 | 3. Commit your changes (`git commit -am 'Add some feature'`) 302 | 4. Push to the branch (`git push origin my-new-feature`) 303 | 5. Create new Pull Request 304 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require 'rake/testtask' 3 | Bundler::GemHelper.install_tasks 4 | 5 | task :console do 6 | puts "Loading development console..." 7 | system("irb -r active_model_otp") 8 | end 9 | 10 | task :help do 11 | puts "Available rake tasks: " 12 | puts "rake console - Run a IRB console with all enviroment loaded" 13 | puts "rake test - Run tests" 14 | end 15 | 16 | task :test do 17 | Dir.chdir('test') 18 | end 19 | 20 | Rake::TestTask.new(:test) do |t| 21 | t.libs << '../lib' 22 | t.libs << '../test' 23 | t.test_files = FileList['*_test.rb'] 24 | t.verbose = false 25 | end 26 | 27 | task :default => :test 28 | -------------------------------------------------------------------------------- /active_model_otp.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'active_model/otp/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "active_model_otp" 8 | spec.version = ActiveModel::Otp::VERSION 9 | spec.authors = ["Guillermo Iguaran", "Roberto Miranda", "Heapsource"] 10 | spec.email = ["guilleiguaran@gmail.com", "rjmaltamar@gmail.com", "hello@firebase.co"] 11 | spec.description = %q{Adds methods to set and authenticate against one time passwords 2FA(Two factor Authentication). Inspired in AM::SecurePassword"} 12 | spec.summary = "Adds methods to set and authenticate against one time passwords." 13 | spec.homepage = "" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files`.split($/) 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.required_ruby_version = ">= 2.3" 22 | 23 | spec.add_dependency "activemodel" 24 | spec.add_dependency "rotp", "~> 6.3.0" 25 | 26 | spec.add_development_dependency "activerecord" 27 | spec.add_development_dependency "rake" 28 | spec.add_development_dependency "minitest", "~> 5.4.2" 29 | spec.add_development_dependency "appraisal" 30 | 31 | if RUBY_PLATFORM == "java" 32 | spec.add_development_dependency "activerecord-jdbcsqlite3-adapter" 33 | else 34 | spec.add_development_dependency "sqlite3" 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /gemfiles/rails_4.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activemodel", "~> 4.2" 6 | gem "sqlite3", "~> 1.3.6" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails_5.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activemodel", "~> 5.0" 6 | gem "activemodel-serializers-xml" 7 | gem "sqlite3", "~> 1.3.6" 8 | 9 | gemspec path: "../" 10 | -------------------------------------------------------------------------------- /gemfiles/rails_5.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activemodel", "~> 5.1" 6 | gem "activemodel-serializers-xml" 7 | gem "sqlite3", "~> 1.3.6" 8 | 9 | gemspec path: "../" 10 | -------------------------------------------------------------------------------- /gemfiles/rails_5.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activemodel", "~> 5.2" 6 | gem "activemodel-serializers-xml" 7 | gem "sqlite3", "~> 1.3.6" 8 | 9 | gemspec path: "../" 10 | -------------------------------------------------------------------------------- /gemfiles/rails_6.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 6.0" 6 | gem "activemodel", "~> 6.0" 7 | gem "activemodel-serializers-xml" 8 | gem "sqlite3", "~> 1.4" 9 | 10 | gemspec path: "../" 11 | -------------------------------------------------------------------------------- /gemfiles/rails_6.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 6.1" 6 | gem "activemodel", "~> 6.1" 7 | gem "activemodel-serializers-xml" 8 | gem "sqlite3", "~> 1.4" 9 | 10 | gemspec path: "../" 11 | -------------------------------------------------------------------------------- /gemfiles/rails_7.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.0" 6 | gem "activemodel", "~> 7.0" 7 | gem "activemodel-serializers-xml" 8 | gem "sqlite3", "~> 1.6" 9 | 10 | gemspec path: "../" 11 | -------------------------------------------------------------------------------- /lib/active_model/one_time_password.rb: -------------------------------------------------------------------------------- 1 | module ActiveModel 2 | module OneTimePassword 3 | extend ActiveSupport::Concern 4 | 5 | OTP_DEFAULT_COLUMN_NAME = 'otp_secret_key'.freeze 6 | OTP_DEFAULT_COUNTER_COLUMN_NAME = 'otp_counter'.freeze 7 | OTP_DEFAULT_BACKUP_CODES_COLUMN_NAME = 'otp_backup_codes'.freeze 8 | OTP_DEFAULT_DIGITS = 6 9 | OTP_DEFAULT_BACKUP_CODES_COUNT = 12 10 | OTP_COUNTER_ENABLED_BY_DEFAULT = false 11 | OTP_BACKUP_CODES_ENABLED_BY_DEFAULT = false 12 | 13 | module ClassMethods 14 | def has_one_time_password(options = {}) 15 | cattr_accessor :otp_column_name, :otp_counter_column_name, 16 | :otp_backup_codes_column_name, :otp_after_column_name 17 | class_attribute :otp_digits, :otp_counter_based, 18 | :otp_backup_codes_count, :otp_one_time_backup_codes, 19 | :otp_interval 20 | 21 | self.otp_column_name = (options[:column_name] || OTP_DEFAULT_COLUMN_NAME).to_s 22 | self.otp_digits = options[:length] || OTP_DEFAULT_DIGITS 23 | self.otp_counter_based = options[:counter_based] || OTP_COUNTER_ENABLED_BY_DEFAULT 24 | self.otp_counter_column_name = (options[:counter_column_name] || OTP_DEFAULT_COUNTER_COLUMN_NAME).to_s 25 | self.otp_interval = options[:interval] 26 | self.otp_after_column_name = options[:after_column_name] 27 | self.otp_backup_codes_column_name = (options[:backup_codes_column_name] || OTP_DEFAULT_BACKUP_CODES_COLUMN_NAME).to_s 28 | self.otp_backup_codes_count = options[:backup_codes_count] || OTP_DEFAULT_BACKUP_CODES_COUNT 29 | self.otp_one_time_backup_codes = options[:one_time_backup_codes] || OTP_BACKUP_CODES_ENABLED_BY_DEFAULT 30 | 31 | include InstanceMethodsOnActivation 32 | 33 | before_create(**options.slice(:if, :unless)) do 34 | self.otp_regenerate_secret if !otp_column 35 | self.otp_regenerate_counter if otp_counter_based && !otp_counter 36 | otp_regenerate_backup_codes if backup_codes_enabled? 37 | end 38 | 39 | if respond_to?(:attributes_protected_by_default) 40 | def self.attributes_protected_by_default #:nodoc: 41 | super + [otp_column_name, otp_counter_column_name] 42 | end 43 | end 44 | end 45 | 46 | # Defaults to 160 bit long secret 47 | # (meaning a 32 character long base32 secret) 48 | def otp_random_secret(length = 20) 49 | ROTP::Base32.random(length) 50 | end 51 | end 52 | 53 | module InstanceMethodsOnActivation 54 | def otp_regenerate_secret 55 | self.otp_column = self.class.otp_random_secret 56 | end 57 | 58 | def otp_regenerate_counter 59 | self.otp_counter = 1 60 | end 61 | 62 | def authenticate_otp(code, options = {}) 63 | return false if code.nil? || code.empty? 64 | return true if backup_codes_enabled? && authenticate_backup_code(code) 65 | 66 | if otp_counter_based 67 | otp_counter == authenticate_hotp(code, options) 68 | else 69 | authenticate_totp(code, options).present? 70 | end 71 | end 72 | 73 | def otp_code(options = {}) 74 | if otp_counter_based 75 | hotp_code(options) 76 | else 77 | totp_code(options) 78 | end 79 | end 80 | 81 | def provisioning_uri(account = nil, options = {}) 82 | account ||= self.email if self.respond_to?(:email) 83 | account ||= "" 84 | 85 | if otp_counter_based 86 | ROTP::HOTP.new(otp_column, options).provisioning_uri(account, self.otp_counter) 87 | else 88 | ROTP::TOTP.new(otp_column, options).provisioning_uri(account) 89 | end 90 | end 91 | 92 | def otp_column 93 | self.public_send(self.class.otp_column_name) 94 | end 95 | 96 | def otp_column=(attr) 97 | self.public_send("#{self.class.otp_column_name}=", attr) 98 | end 99 | 100 | def otp_counter 101 | if self.class.otp_counter_column_name != "otp_counter" 102 | self.public_send(self.class.otp_counter_column_name) 103 | else 104 | super 105 | end 106 | end 107 | 108 | def otp_counter=(attr) 109 | if self.class.otp_counter_column_name != "otp_counter" 110 | self.public_send("#{self.class.otp_counter_column_name}=", attr) 111 | else 112 | super 113 | end 114 | end 115 | 116 | def serializable_hash(options = nil) 117 | options ||= {} 118 | options[:except] = Array(options[:except]) 119 | options[:except] << self.class.otp_column_name 120 | 121 | super(options) 122 | end 123 | 124 | def otp_regenerate_backup_codes 125 | otp = ROTP::OTP.new(otp_column) 126 | backup_codes = Array.new(self.class.otp_backup_codes_count) do 127 | otp.generate_otp((SecureRandom.random_number(9e5) + 1e5).to_i) 128 | end 129 | 130 | public_send("#{self.class.otp_backup_codes_column_name}=", backup_codes) 131 | end 132 | 133 | def backup_codes_enabled? 134 | self.class.attribute_method?(self.class.otp_backup_codes_column_name) 135 | end 136 | 137 | private 138 | 139 | def authenticate_hotp(code, options = {}) 140 | hotp = ROTP::HOTP.new(otp_column, digits: otp_digits) 141 | result = hotp.verify(code, otp_counter) 142 | 143 | if result && options[:auto_increment] 144 | self.otp_counter += 1 145 | save if respond_to?(:changed?) && !new_record? 146 | end 147 | 148 | result 149 | end 150 | 151 | def authenticate_totp(code, options = {}) 152 | totp = ROTP::TOTP.new(otp_column, digits: otp_digits, interval: otp_interval) 153 | 154 | otp_after = public_send(otp_after_column_name) if otp_after_column_name_enabled? 155 | 156 | totp.verify(code, drift_behind: options[:drift] || 0, after: otp_after).tap do |updated_last_otp_at| 157 | updated_last_otp_at && otp_after_column_name_enabled? && update(otp_after_column_name => updated_last_otp_at) 158 | end 159 | end 160 | 161 | def otp_after_column_name_enabled? 162 | otp_after_column_name && respond_to?(otp_after_column_name) 163 | end 164 | 165 | def hotp_code(options = {}) 166 | if options[:auto_increment] 167 | self.otp_counter += 1 168 | save if respond_to?(:changed?) && !new_record? 169 | end 170 | 171 | ROTP::HOTP.new(otp_column, digits: otp_digits).at(otp_counter) 172 | end 173 | 174 | def totp_code(options = {}) 175 | time = options.is_a?(Hash) ? options.fetch(:time, Time.now) : options 176 | 177 | ROTP::TOTP.new(otp_column, digits: otp_digits, interval: otp_interval).at(time) 178 | end 179 | 180 | def authenticate_backup_code(code) 181 | backup_codes_column_name = self.class.otp_backup_codes_column_name 182 | backup_codes = public_send(backup_codes_column_name) 183 | return false unless backup_codes.present? && backup_codes.include?(code) 184 | 185 | if self.class.otp_one_time_backup_codes 186 | backup_codes.delete(code) 187 | public_send("#{backup_codes_column_name}=", backup_codes) 188 | save if respond_to?(:changed?) && !new_record? 189 | end 190 | 191 | true 192 | end 193 | end 194 | end 195 | end 196 | -------------------------------------------------------------------------------- /lib/active_model/otp/version.rb: -------------------------------------------------------------------------------- 1 | module ActiveModel 2 | module Otp 3 | VERSION = '2.3.4'.freeze 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/active_model_otp.rb: -------------------------------------------------------------------------------- 1 | require "active_model" 2 | require "active_support/core_ext/module/attribute_accessors" 3 | require "cgi" 4 | require "rotp" 5 | require "active_model/one_time_password" 6 | 7 | ActiveSupport.on_load(:active_record) do 8 | include ActiveModel::OneTimePassword 9 | end 10 | -------------------------------------------------------------------------------- /test/models/activerecord_user.rb: -------------------------------------------------------------------------------- 1 | class ActiverecordUser < ActiveRecord::Base 2 | has_one_time_password counter_based: true 3 | end 4 | -------------------------------------------------------------------------------- /test/models/after_user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AfterUser < ActiveRecord::Base 4 | has_one_time_password after_column_name: :last_otp_at 5 | end 6 | -------------------------------------------------------------------------------- /test/models/default_interval_user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DefaultIntervalUser < ActiveRecord::Base 4 | has_one_time_password interval: 500 5 | end 6 | -------------------------------------------------------------------------------- /test/models/interval_user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class IntervalUser < ActiveRecord::Base 4 | has_one_time_password interval: 2 5 | end 6 | -------------------------------------------------------------------------------- /test/models/member.rb: -------------------------------------------------------------------------------- 1 | class Member 2 | extend ActiveModel::Callbacks 3 | include ActiveModel::Validations 4 | include ActiveModel::OneTimePassword 5 | 6 | define_model_callbacks :create 7 | attr_accessor :otp_secret_key, :otp_counter, :email 8 | 9 | has_one_time_password counter_based: true 10 | end 11 | -------------------------------------------------------------------------------- /test/models/opt_in_two_factor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class OptInTwoFactor 4 | extend ActiveModel::Callbacks 5 | include ActiveModel::Validations 6 | include ActiveModel::OneTimePassword 7 | 8 | define_model_callbacks :create 9 | attr_accessor :otp_secret_key, :email 10 | 11 | has_one_time_password unless: :otp_opt_in? 12 | 13 | def otp_opt_in? 14 | true 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/models/user.rb: -------------------------------------------------------------------------------- 1 | class User 2 | extend ActiveModel::Callbacks 3 | include ActiveModel::Serializers::JSON 4 | include ActiveModel::Validations 5 | include ActiveModel::OneTimePassword 6 | 7 | define_model_callbacks :create 8 | attr_accessor :otp_secret_key, :otp_backup_codes, :email 9 | 10 | has_one_time_password one_time_backup_codes: true 11 | 12 | def attributes 13 | { "otp_secret_key" => otp_secret_key, "email" => email } 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/models/visitor.rb: -------------------------------------------------------------------------------- 1 | class Visitor 2 | extend ActiveModel::Callbacks 3 | include ActiveModel::Validations 4 | include ActiveModel::OneTimePassword 5 | 6 | define_model_callbacks :create 7 | attr_accessor :otp_token, :email 8 | 9 | has_one_time_password column_name: :otp_token, length: 4 10 | end 11 | 12 | -------------------------------------------------------------------------------- /test/one_time_password_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class OtpTest < MiniTest::Test 6 | include ActiveSupport::Testing::TimeHelpers 7 | 8 | def setup 9 | @user = User.new 10 | @user.email = 'roberto@heapsource.com' 11 | @user.run_callbacks :create 12 | 13 | @visitor = Visitor.new 14 | @visitor.email = 'roberto@heapsource.com' 15 | @visitor.run_callbacks :create 16 | 17 | @member = Member.new 18 | @member.email = nil 19 | @member.run_callbacks :create 20 | 21 | @ar_user = ActiverecordUser.new 22 | @ar_user.email = 'roberto@heapsource.com' 23 | @ar_user.run_callbacks :create 24 | 25 | @opt_in = OptInTwoFactor.new 26 | @opt_in.email = 'roberto@heapsource.com' 27 | @opt_in.run_callbacks :create 28 | 29 | @after_user = AfterUser.new 30 | @after_user.email = 'roberto@heapsource.com' 31 | @after_user.run_callbacks :create 32 | end 33 | 34 | def test_authenticate_with_otp 35 | code = @user.otp_code 36 | assert @user.authenticate_otp(code) 37 | 38 | code = @visitor.otp_code 39 | assert @visitor.authenticate_otp(code) 40 | end 41 | 42 | def test_authenticate_with_otp_passing_false_or_empty_codes 43 | refute @user.authenticate_otp(nil) 44 | refute @user.authenticate_otp('') 45 | 46 | refute @visitor.authenticate_otp(nil) 47 | refute @visitor.authenticate_otp('') 48 | 49 | refute @member.authenticate_otp(nil) 50 | refute @member.authenticate_otp('') 51 | 52 | refute @ar_user.authenticate_otp(nil) 53 | refute @ar_user.authenticate_otp('') 54 | 55 | refute @opt_in.authenticate_otp(nil) 56 | refute @opt_in.authenticate_otp('') 57 | end 58 | 59 | def test_counter_based_otp 60 | code = @member.otp_code 61 | assert @member.authenticate_otp(code) 62 | assert @member.authenticate_otp(code, auto_increment: true) 63 | assert !@member.authenticate_otp(code) 64 | @member.otp_counter -= 1 65 | assert @member.authenticate_otp(code) 66 | assert code == @member.otp_code 67 | assert code != @member.otp_code(auto_increment: true) 68 | end 69 | 70 | def test_counter_based_otp_active_record 71 | code = @ar_user.otp_code 72 | assert @ar_user.authenticate_otp(code) 73 | assert @ar_user.authenticate_otp(code, auto_increment: true) 74 | assert !@ar_user.authenticate_otp(code) 75 | @ar_user.otp_counter -= 1 76 | assert @ar_user.authenticate_otp(code) 77 | assert code == @ar_user.otp_code 78 | assert code != @ar_user.otp_code(auto_increment: true) 79 | end 80 | 81 | def test_opt_in_two_factor 82 | assert @opt_in.otp_column.nil? 83 | 84 | @opt_in.otp_regenerate_secret 85 | code = @opt_in.otp_code 86 | assert_equal true, @opt_in.authenticate_otp(code) 87 | end 88 | 89 | def test_authenticate_with_otp_when_drift_is_allowed 90 | code = @user.otp_code(Time.now - 30) 91 | assert_equal true, @user.authenticate_otp(code, drift: 60) 92 | 93 | code = @visitor.otp_code(Time.now - 30) 94 | assert_equal true, @visitor.authenticate_otp(code, drift: 60) 95 | end 96 | 97 | def test_authenticate_with_otp_when_after_is_allowed 98 | code = @user.otp_code 99 | assert_equal true, @user.authenticate_otp(code) 100 | assert_equal true, @user.authenticate_otp(code) 101 | 102 | code = @after_user.otp_code 103 | assert_equal true, @after_user.authenticate_otp(code) 104 | assert_equal false, @after_user.authenticate_otp(code) 105 | assert_equal false, @after_user.authenticate_otp('1111111') 106 | assert_equal false, @after_user.authenticate_otp(code) 107 | end 108 | 109 | def test_authenticate_with_backup_code 110 | backup_code = @user.public_send(@user.otp_backup_codes_column_name).first 111 | assert_equal true, @user.authenticate_otp(backup_code) 112 | 113 | backup_code = @user.public_send(@user.otp_backup_codes_column_name).last 114 | @user.otp_regenerate_backup_codes 115 | assert_equal true, !@user.authenticate_otp(backup_code) 116 | end 117 | 118 | def test_authenticate_with_one_time_backup_code 119 | backup_code = @user.public_send(@user.otp_backup_codes_column_name).first 120 | assert_equal true, @user.authenticate_otp(backup_code) 121 | assert_equal true, !@user.authenticate_otp(backup_code) 122 | end 123 | 124 | def test_otp_code 125 | assert_match(/^\d{6}$/, @user.otp_code.to_s) 126 | assert_match(/^\d{4}$/, @visitor.otp_code.to_s) 127 | end 128 | 129 | def test_otp_code_with_specific_length 130 | assert_match(/^\d{4}$/, @visitor.otp_code(2160).to_s) 131 | assert_operator(@visitor.otp_code(2160).to_s.length, :<=, 4) 132 | end 133 | 134 | def test_otp_code_without_specific_length 135 | assert_match(/^\d{6}$/, @user.otp_code(2160).to_s) 136 | assert @user.otp_code(2160).to_s.length <= 6 137 | end 138 | 139 | def test_provisioning_uri_with_provided_account 140 | totp = %r{^otpauth://totp/roberto\?secret=\w{32}$} 141 | hotp = %r{^otpauth://hotp/roberto\?secret=\w{32}&counter=1$} 142 | 143 | assert_match totp, @user.provisioning_uri('roberto') 144 | assert_match totp, @visitor.provisioning_uri('roberto') 145 | assert_match hotp, @member.provisioning_uri('roberto') 146 | end 147 | 148 | def test_provisioning_uri_with_email_field 149 | totp = %r{^otpauth://totp/roberto%40heapsource\.com\?secret=\w{32}$} 150 | hotp = %r{^otpauth://hotp/\?secret=\w{32}&counter=1$} 151 | 152 | assert_match totp, @user.provisioning_uri 153 | assert_match totp, @visitor.provisioning_uri 154 | assert_match hotp, @member.provisioning_uri 155 | end 156 | 157 | def test_provisioning_uri_with_options 158 | account = %r{ 159 | ^otpauth://totp/Example\:roberto\?secret=\w{32}&issuer=Example$ 160 | }x 161 | 162 | email = %r{ 163 | ^otpauth://totp/Example\:roberto%40heapsource\.com\?secret=\w{32} 164 | &issuer=Example$ 165 | }x 166 | 167 | assert_match account, @user.provisioning_uri('roberto', issuer: 'Example') 168 | assert_match account, @visitor.provisioning_uri('roberto', issuer: 'Example') 169 | 170 | assert_match email, @user.provisioning_uri(nil, issuer: 'Example') 171 | assert_match email, @visitor.provisioning_uri(nil, issuer: 'Example') 172 | end 173 | 174 | def test_provisioning_uri_with_incremented_counter 175 | 2.times { @member.otp_code(auto_increment: true) } 176 | 177 | hotp = %r{^otpauth://hotp/\?secret=\w{32}&counter=3$} 178 | 179 | assert_match hotp, @member.provisioning_uri 180 | end 181 | 182 | def test_regenerate_otp 183 | secret = @user.otp_column 184 | @user.otp_regenerate_secret 185 | assert secret != @user.otp_column 186 | end 187 | 188 | def test_hide_secret_key_in_serialize 189 | refute_match(/otp_secret_key/, @user.to_json) 190 | end 191 | 192 | def test_otp_random_secret 193 | assert_match(/^.{32}$/, @user.class.otp_random_secret) 194 | end 195 | 196 | def test_otp_interval 197 | @interval_user = IntervalUser.new 198 | @interval_user.email = 'roberto@heapsource.com' 199 | @interval_user.run_callbacks :create 200 | otp_code = @interval_user.otp_code 201 | 2.times { assert_match(otp_code, @interval_user.otp_code) } 202 | 203 | travel 5.seconds do 204 | refute_match(otp_code, @interval_user.otp_code) 205 | end 206 | end 207 | 208 | def test_otp_default_interval 209 | @default_interval_user = DefaultIntervalUser.new 210 | @default_interval_user.email = 'roberto@heapsource.com' 211 | @default_interval_user.run_callbacks :create 212 | otp_code = @default_interval_user.otp_code 213 | 214 | 2.times { assert_match(otp_code, @default_interval_user.otp_code) } 215 | 216 | travel 5.seconds do 217 | assert_match(otp_code, @default_interval_user.otp_code) 218 | end 219 | end 220 | end 221 | -------------------------------------------------------------------------------- /test/schema.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define do 2 | self.verbose = false 3 | 4 | create_table :activerecord_users, force: true do |t| 5 | t.string :key 6 | t.string :email 7 | t.integer :otp_counter 8 | t.string :otp_secret_key 9 | t.timestamps 10 | end 11 | 12 | create_table :interval_users, force: true do |t| 13 | t.string :key 14 | t.string :email 15 | t.integer :otp_counter 16 | t.string :otp_secret_key 17 | t.timestamps 18 | end 19 | 20 | create_table :default_interval_users, force: true do |t| 21 | t.string :key 22 | t.string :email 23 | t.integer :otp_counter 24 | t.string :otp_secret_key 25 | t.timestamps 26 | end 27 | 28 | create_table :after_users, force: true do |t| 29 | t.string :key 30 | t.string :email 31 | t.integer :otp_counter 32 | t.string :otp_secret_key 33 | t.integer :last_otp_at 34 | t.timestamps 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | testdir = File.dirname(__FILE__) 2 | $LOAD_PATH.unshift testdir unless $LOAD_PATH.include?(testdir) 3 | 4 | libdir = File.dirname(File.dirname(__FILE__)) + '/lib' 5 | $LOAD_PATH.unshift libdir unless $LOAD_PATH.include?(libdir) 6 | 7 | require "rubygems" 8 | require "active_model_otp" 9 | require "minitest/autorun" 10 | require "minitest/unit" 11 | require "active_record" 12 | require "active_support/testing/time_helpers" 13 | 14 | begin 15 | require "activemodel-serializers-xml" 16 | rescue LoadError 17 | end 18 | 19 | ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:" 20 | load "#{ File.dirname(__FILE__) }/schema.rb" 21 | 22 | Dir["models/*.rb"].each {|file| require file } 23 | --------------------------------------------------------------------------------