active_model_otp 1.1.0 → 2.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 9d9aedb666900aee840275449fcdb4c2fb912c27
4
- data.tar.gz: de9ef98a499709f05a3a836cd50242c74f8b1fdf
2
+ SHA256:
3
+ metadata.gz: bdc023d65f130fca41bde8f20a7094e587c7940240ae5b36778e54d2efcf76ac
4
+ data.tar.gz: 1dab4dd23328538a3a3c8d9a712b9a16d01673b3d997538169126966c653cdf6
5
5
  SHA512:
6
- metadata.gz: bc0e46abcdae3948c4ea3bf51d92848ecc887d83efb611f7828b830d45f4d2372e0aa45d1c229e627de6e5d404c1ebb626d4c130ed6a3f14980cae6de33da122
7
- data.tar.gz: 302bcfde4c0b3d33355cf44ed9b8896a1f83c615c3fafdc9c741ae171955ab78197e5de9b31ac0a4cc7d60fd801c2718c08949f7fc724e8b821e852d10a67179
6
+ metadata.gz: f896afd30554da73cff730f4277aefe8805b4cbb3da0bf550039b14b8d4611374498b1cf4b0eae2fe80d7a35bdaafdc0ae1558ffbdea3f2f708d2334a81e1af2
7
+ data.tar.gz: 0d3b2a4fe8d0e26e9c06bd71e471bcabf4a048f1f1bd531691c0afbc5feda757147ca045ddedec34e587240b0b1337960a9248c0340e6f57ec5d7057cec776ea
data/.gitignore CHANGED
@@ -16,3 +16,4 @@ test/tmp
16
16
  test/version_tmp
17
17
  tmp
18
18
  .ruby-version
19
+ gemfiles/*.lock
data/.travis.yml CHANGED
@@ -1,10 +1,43 @@
1
1
  rvm:
2
- - 1.9.3
3
- - 2.0.0
4
- - 2.1
2
+ - 2.3
3
+ - 2.4
4
+ - 2.5
5
+ - 2.6
6
+ - 2.7
7
+ - 3.0
8
+ - ruby-head
9
+ gemfile:
10
+ - gemfiles/rails_4.2.gemfile
11
+ - gemfiles/rails_5.0.gemfile
12
+ - gemfiles/rails_5.1.gemfile
13
+ - gemfiles/rails_5.2.gemfile
14
+ - gemfiles/rails_6.0.gemfile
15
+ - gemfiles/rails_6.1.gemfile
5
16
  matrix:
6
- include:
7
- - rvm: jruby
8
- env: JRUBY_OPTS="--1.9 --server -Xcext.enabled=true"
17
+ exclude:
18
+ - rvm: 2.3
19
+ gemfile: gemfiles/rails_6.0.gemfile
20
+ - rvm: 2.3
21
+ gemfile: gemfiles/rails_6.1.gemfile
22
+ - rvm: 2.4
23
+ gemfile: gemfiles/rails_6.0.gemfile
24
+ - rvm: 2.4
25
+ gemfile: gemfiles/rails_6.1.gemfile
26
+ - rvm: 2.7
27
+ gemfile: gemfiles/rails_4.2.gemfile
28
+ - rvm: 3.0
29
+ gemfile: gemfiles/rails_4.2.gemfile
30
+ - rvm: 3.0
31
+ gemfile: gemfiles/rails_5.0.gemfile
32
+ - rvm: 3.0
33
+ gemfile: gemfiles/rails_5.1.gemfile
34
+ - rvm: 3.0
35
+ gemfile: gemfiles/rails_5.2.gemfile
36
+ fast_finish: true
37
+ allow_failures:
38
+ - rvm: ruby-head
39
+ # include:
40
+ # - rvm: jruby
41
+ # env: JRUBY_OPTS="--1.9 --server -Xcext.enabled=true"
9
42
  notifications:
10
43
  email: false
data/Appraisals ADDED
@@ -0,0 +1,36 @@
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
data/CHANGELOG.md CHANGED
@@ -1,10 +1 @@
1
- #unreleased
2
- #v1.0.0
3
- - Avoid overriding predefined otp_column value when initializing resource (Ilan Stern) https://github.com/heapsource/active_model_otp/pull/10
4
- - Pad OTP codes with less than 6 digits (Johan Brissmyr) https://github.com/heapsource/active_model_otp/pull/7
5
- - Get rid of deprecation warnings in Rails 4.1 (Nick DeMonner)
6
-
7
- #v0.1.0
8
- - OTP codes can be in 5 or 6 digits (André Luis Leal Cardoso Junior)
9
- - Require 'cgi', rotp needs it for encoding parameters (André Luis Leal Cardoso Junior)
10
- - Change column name for otp secret key (robertomiranda)
1
+ CHANGELOG it's been deprecated in favor of https://github.com/heapsource/active_model_otp/releases
data/LICENSE.txt CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2013 Guillermo Iguaran, Roberto Miranda, Firebase.co
1
+ Copyright (c) 2013-2019 Roberto Miranda, Guillermo Iguaran and contributors.
2
2
 
3
3
  MIT License
4
4
 
data/README.md CHANGED
@@ -1,8 +1,16 @@
1
1
  [![Build Status](https://travis-ci.org/heapsource/active_model_otp.png)](https://travis-ci.org/heapsource/active_model_otp)
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
+
2
5
 
3
6
  # ActiveModel::Otp
4
7
 
5
- **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 4.0 (AMo::Otp is also compatible with Rails 3.x versions) we're going to use an User model and some authentication to it. Inspired in AM::SecurePassword
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
6
14
 
7
15
  ## Installation
8
16
 
@@ -14,7 +22,7 @@ And then execute:
14
22
 
15
23
  $ bundle
16
24
 
17
- Or install it yourself as:
25
+ Or install it yourself as follows:
18
26
 
19
27
  $ gem install active_model_otp
20
28
 
@@ -29,33 +37,32 @@ rails g migration AddOtpSecretKeyToUsers otp_secret_key:string
29
37
  create db/migrate/20130707010931_add_otp_secret_key_to_users.rb
30
38
  ```
31
39
 
32
- 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 tell it will be use TFA.
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.
33
41
 
34
42
  ```ruby
35
- class User < ActiveRecord::Base
43
+ class User < ApplicationRecord
36
44
  has_one_time_password
37
45
  end
38
46
  ```
39
47
 
40
48
  Note: If you're adding this to an existing user model you'll need to generate *otp_secret_key* with a migration like:
41
49
  ```ruby
42
- User.all.each { |user| user.update_attribute(:otp_secret_key, ROTP::Base32.random_base32) }
50
+ User.find_each { |user| user.update_attribute(:otp_secret_key, User.otp_random_secret) }
43
51
  ```
44
52
 
45
- For use a custom column for store the secret key field you can us the column_name option
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.
46
54
 
47
55
  ```ruby
48
- class User < ActiveRecord::Base
49
- has_one_time_password column_name: :my_otp_secret_column
56
+ class User < ApplicationRecord
57
+ has_one_time_password column_name: :my_otp_secret_column, length: 4
50
58
  end
51
59
  ```
52
60
 
61
+ ## Usage
53
62
 
54
- ##Usage
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.
55
64
 
56
- The has_one_time_password sentence provides to the model some useful methods in order to implement our TFA system. AMo:Otp generates one time passwords according to [RFC 4226](http://tools.ietf.org/html/rfc4226) and the [HOTP RFC](http://tools.ietf.org/html/draft-mraihi-totp-timebased-00). This is compatible with Google Authenticator apps available for Android and iPhone, and now in use on GMail.
57
-
58
- The otp_secret_key is saved automatically when a object is created,
65
+ The otp_secret_key is saved automatically when an object is created,
59
66
 
60
67
  ```ruby
61
68
  user = User.create(email: "hello@heapsource.com")
@@ -63,9 +70,9 @@ user.otp_secret_key
63
70
  => "jt3gdd2qm6su5iqh"
64
71
  ```
65
72
 
66
- **Note:** You can fork the applications for [iPhone](https://github.com/heapsource/google-authenticator) & [Android](https://github.com/heapsource/google-authenticator.android) and customize it
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
67
74
 
68
- ### Getting current code (ex. to send via SMS)
75
+ ### Getting current code (e.g. to send via SMS)
69
76
  ```ruby
70
77
  user.otp_code # => '186522'
71
78
  sleep 30
@@ -73,9 +80,6 @@ user.otp_code # => '850738'
73
80
 
74
81
  # Override current time
75
82
  user.otp_code(time: Time.now + 3600) # => '317438'
76
-
77
- # Don't zero-pad to six digits
78
- user.otp_code(padding: false) # => '438'
79
83
  ```
80
84
 
81
85
  ### Authenticating using a code
@@ -94,19 +98,120 @@ sleep 30 # lets wait again
94
98
  user.authenticate_otp('186522', drift: 60) # => true
95
99
  ```
96
100
 
101
+ ## Counter based OTP
102
+
103
+ An additonal counter field is required in our ``User`` Model
104
+
105
+ ```ruby
106
+ rails g migration AddCounterForOtpToUsers otp_counter:integer
107
+ =>
108
+ invoke active_record
109
+ create db/migrate/20130707010931_add_counter_for_otp_to_users.rb
110
+ ```
111
+
112
+ Set default value for otp_counter to 0.
113
+ ```ruby
114
+ change_column :users, :otp_counter, :integer, default: 0
115
+ ```
116
+
117
+ In addition set the counter flag option to true
118
+
119
+ ```ruby
120
+ class User < ApplicationRecord
121
+ has_one_time_password counter_based: true
122
+ end
123
+ ```
124
+
125
+ And for a custom counter column
126
+
127
+ ```ruby
128
+ class User < ApplicationRecord
129
+ has_one_time_password counter_based: true, counter_column_name: :my_otp_secret_counter_column
130
+ end
131
+ ```
132
+
133
+ Authentication is done the same. You can manually adjust the counter for your usage or set auto_increment on success to true.
134
+
135
+ ```ruby
136
+ user.authenticate_otp('186522') # => true
137
+ user.authenticate_otp('186522', auto_increment: true) # => true
138
+ user.authenticate_otp('186522') # => false
139
+ user.otp_counter -= 1
140
+ user.authenticate_otp('186522') # => true
141
+ ```
142
+
143
+ When retrieving an ```otp_code``` you can also pass the ```auto_increment``` option.
144
+
145
+ ```ruby
146
+ user.otp_code # => '186522'
147
+ user.otp_code # => '186522'
148
+ user.otp_code(auto_increment: true) # => '768273'
149
+ user.otp_code(auto_increment: true) # => '002811'
150
+ user.otp_code # => '002811'
151
+ ```
152
+
153
+ ## Backup codes
154
+
155
+ 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.
156
+
157
+ ```ruby
158
+ rails g migration AddOtpBackupCodesToUsers otp_backup_codes:text
159
+ =>
160
+ invoke active_record
161
+ create db/migrate/20210126030834_add_otp_backup_codes_to_users.rb
162
+ ```
163
+
164
+ You can change backup codes column name by option `backup_codes_column_name`:
165
+
166
+ ```ruby
167
+ class User < ApplicationRecord
168
+ has_one_time_password backup_codes_column_name: 'secret_codes'
169
+ end
170
+ ```
171
+
172
+ 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.
173
+
174
+ After that user can use one of automatically generated backup codes for authentication using same method `authenticate_otp`.
175
+
176
+ By default it generates 12 backup codes. You can change it by option `backup_codes_count`:
177
+
178
+ ```ruby
179
+ class User < ApplicationRecord
180
+ has_one_time_password backup_codes_count: 6
181
+ end
182
+ ```
183
+
184
+ By default each backup code can be reused an infinite number of times. You can
185
+ change it with option `one_time_backup_codes`:
186
+
187
+ ```ruby
188
+ class User < ApplicationRecord
189
+ has_one_time_password one_time_backup_codes: true
190
+ end
191
+ ```
192
+
193
+ ```ruby
194
+ user.authenticate_otp('186522') # => true
195
+ user.authenticate_otp('186522') # => false
196
+ ```
197
+
97
198
  ## Google Authenticator Compatible
98
199
 
99
200
  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.
100
201
 
101
202
  ```ruby
102
- # Use you user's emails for generate the provisioning_url
203
+ # Use your user's email address to generate the provisioning_url
103
204
  user.provisioning_uri # => 'otpauth://totp/hello@heapsource.com?secret=2z6hxkdwi3uvrnpn'
104
205
 
105
- # Use a custom fied for generate the provisioning_url
206
+ # Use a custom field to generate the provisioning_url
106
207
  user.provisioning_uri("hello") # => 'otpauth://totp/hello?secret=2z6hxkdwi3uvrnpn'
208
+
209
+ # You can customize the generated url, by passing a hash of Options
210
+ # `:issuer` lets you set the Issuer name in Google Authenticator, so it doesn't show as a blank entry.
211
+ user.provisioning_uri(nil, issuer: 'MYAPP') #=> 'otpauth://totp/hello@heapsource.com?secret=2z6hxkdwi3uvrnpn&issuer=MYAPP'
107
212
  ```
108
213
 
109
- This can then be rendered as a QR Code which can then be scanned and added to the users list of OTP credentials.
214
+ This can then be rendered as a QR Code which can be scanned and added to the users list of OTP credentials.
110
215
 
111
216
  ### Working example
112
217
 
@@ -118,6 +223,7 @@ Now run the following and compare the output
118
223
 
119
224
  ```ruby
120
225
  require "active_model_otp"
226
+
121
227
  class User
122
228
  extend ActiveModel::Callbacks
123
229
  include ActiveModel::Validations
@@ -128,6 +234,7 @@ class User
128
234
 
129
235
  has_one_time_password
130
236
  end
237
+
131
238
  user = User.new
132
239
  user.email = 'roberto@heapsource.com'
133
240
  user.otp_secret_key = "2z6hxkdwi3uvrnpn"
@@ -137,10 +244,10 @@ puts "Current code #{user.otp_code}"
137
244
  **Note:** otp_secret_key must be generated using RFC 3548 base32 key strings (for compatilibity with google authenticator)
138
245
 
139
246
  ### Useful Examples
140
-
247
+ - [Drifting Ruby Tutorial](https://www.driftingruby.com/episodes/two-factor-authentication)
141
248
  - [Generate QR code with rqrcode gem](https://github.com/heapsource/active_model_otp/wiki/Generate-QR-code-with-rqrcode-gem)
142
249
  - Generating QR Code with Google Charts API
143
- - [Sendind code via email with Twilio](https://github.com/heapsource/active_model_otp/wiki/Send-code-via-Twilio-SMS)
250
+ - [Sending code via SMS with Twilio](https://github.com/heapsource/active_model_otp/wiki/Send-code-via-Twilio-SMS)
144
251
  - [Using with Mongoid](https://github.com/heapsource/active_model_otp/wiki/Using-with-Mongoid)
145
252
 
146
253
  ## Contributing
@@ -8,7 +8,7 @@ Gem::Specification.new do |spec|
8
8
  spec.version = ActiveModel::Otp::VERSION
9
9
  spec.authors = ["Guillermo Iguaran", "Roberto Miranda", "Heapsource"]
10
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. Inspired in AM::SecurePassword"}
11
+ spec.description = %q{Adds methods to set and authenticate against one time passwords 2FA(Two factor Authentication). Inspired in AM::SecurePassword"}
12
12
  spec.summary = "Adds methods to set and authenticate against one time passwords."
13
13
  spec.homepage = ""
14
14
  spec.license = "MIT"
@@ -18,10 +18,19 @@ Gem::Specification.new do |spec|
18
18
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
19
  spec.require_paths = ["lib"]
20
20
 
21
+ spec.required_ruby_version = ">= 2.3"
22
+
21
23
  spec.add_dependency "activemodel"
22
- spec.add_dependency "rotp"
24
+ spec.add_dependency "rotp", "~> 6.2.0"
23
25
 
24
- spec.add_development_dependency "bundler", "~> 1.3"
26
+ spec.add_development_dependency "activerecord"
25
27
  spec.add_development_dependency "rake"
26
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
27
36
  end
@@ -0,0 +1,8 @@
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: "../"
@@ -0,0 +1,9 @@
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: "../"
@@ -0,0 +1,9 @@
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: "../"
@@ -0,0 +1,9 @@
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: "../"
@@ -0,0 +1,10 @@
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: "../"
@@ -0,0 +1,10 @@
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: "../"
@@ -3,63 +3,184 @@ module ActiveModel
3
3
  extend ActiveSupport::Concern
4
4
 
5
5
  module ClassMethods
6
-
7
6
  def has_one_time_password(options = {})
8
-
9
- cattr_accessor :otp_column_name
10
- class_attribute :otp_digits
7
+ cattr_accessor :otp_column_name, :otp_counter_column_name,
8
+ :otp_backup_codes_column_name
9
+ class_attribute :otp_digits, :otp_counter_based,
10
+ :otp_backup_codes_count, :otp_one_time_backup_codes
11
11
 
12
12
  self.otp_column_name = (options[:column_name] || "otp_secret_key").to_s
13
13
  self.otp_digits = options[:length] || 6
14
14
 
15
+ self.otp_counter_based = (options[:counter_based] || false)
16
+ self.otp_counter_column_name = (options[:counter_column_name] || "otp_counter").to_s
17
+
18
+ self.otp_backup_codes_column_name = (
19
+ options[:backup_codes_column_name] || 'otp_backup_codes'
20
+ ).to_s
21
+ self.otp_backup_codes_count = options[:backup_codes_count] || 12
22
+ self.otp_one_time_backup_codes = (
23
+ options[:one_time_backup_codes] || false
24
+ )
25
+
15
26
  include InstanceMethodsOnActivation
16
27
 
17
- before_create { self.otp_regenerate_secret if !self.otp_column}
28
+ before_create(**options.slice(:if, :unless)) do
29
+ self.otp_regenerate_secret if !otp_column
30
+ self.otp_regenerate_counter if otp_counter_based && !otp_counter
31
+ otp_regenerate_backup_codes if backup_codes_enabled?
32
+ end
18
33
 
19
34
  if respond_to?(:attributes_protected_by_default)
20
35
  def self.attributes_protected_by_default #:nodoc:
21
- super + [self.otp_column_name]
36
+ super + [otp_column_name, otp_counter_column_name]
22
37
  end
23
38
  end
24
39
  end
40
+
41
+ # Defaults to 160 bit long secret
42
+ # (meaning a 32 character long base32 secret)
43
+ def otp_random_secret(length = 20)
44
+ ROTP::Base32.random(length)
45
+ end
25
46
  end
26
47
 
27
48
  module InstanceMethodsOnActivation
28
49
  def otp_regenerate_secret
29
- self.otp_column = ROTP::Base32.random_base32
50
+ self.otp_column = self.class.otp_random_secret
51
+ end
52
+
53
+ def otp_regenerate_counter
54
+ self.otp_counter = 1
30
55
  end
31
56
 
32
57
  def authenticate_otp(code, options = {})
33
- totp = ROTP::TOTP.new(self.otp_column, {digits: self.otp_digits})
34
- if drift = options[:drift]
35
- totp.verify_with_drift(code, drift)
58
+ return true if backup_codes_enabled? && authenticate_backup_code(code)
59
+
60
+ if otp_counter_based
61
+ otp_counter == authenticate_hotp(code, options)
36
62
  else
37
- totp.verify(code)
63
+ authenticate_totp(code, options).present?
38
64
  end
39
65
  end
40
66
 
41
67
  def otp_code(options = {})
42
- if options.is_a? Hash
43
- time = options.fetch(:time, Time.now)
44
- padding = options.fetch(:padding, true)
68
+ if otp_counter_based
69
+ hotp_code(options)
45
70
  else
46
- time = options
47
- padding = true
71
+ totp_code(options)
48
72
  end
49
- ROTP::TOTP.new(self.otp_column, {digits: self.otp_digits}).at(time, padding)
50
73
  end
51
74
 
52
- def provisioning_uri(account = nil)
75
+ def provisioning_uri(account = nil, options = {})
53
76
  account ||= self.email if self.respond_to?(:email)
54
- ROTP::TOTP.new(self.otp_column).provisioning_uri(account)
77
+ account ||= ""
78
+
79
+ if otp_counter_based
80
+ ROTP::HOTP
81
+ .new(otp_column, options)
82
+ .provisioning_uri(account, self.otp_counter)
83
+ else
84
+ ROTP::TOTP
85
+ .new(otp_column, options)
86
+ .provisioning_uri(account)
87
+ end
55
88
  end
56
89
 
57
90
  def otp_column
58
- self.send(self.class.otp_column_name)
91
+ self.public_send(self.class.otp_column_name)
59
92
  end
60
93
 
61
94
  def otp_column=(attr)
62
- self.send("#{self.class.otp_column_name}=", attr)
95
+ self.public_send("#{self.class.otp_column_name}=", attr)
96
+ end
97
+
98
+ def otp_counter
99
+ if self.class.otp_counter_column_name != "otp_counter"
100
+ self.public_send(self.class.otp_counter_column_name)
101
+ else
102
+ super
103
+ end
104
+ end
105
+
106
+ def otp_counter=(attr)
107
+ if self.class.otp_counter_column_name != "otp_counter"
108
+ self.public_send("#{self.class.otp_counter_column_name}=", attr)
109
+ else
110
+ super
111
+ end
112
+ end
113
+
114
+ def serializable_hash(options = nil)
115
+ options ||= {}
116
+ options[:except] = Array(options[:except])
117
+ options[:except] << self.class.otp_column_name
118
+ super(options)
119
+ end
120
+
121
+ def otp_regenerate_backup_codes
122
+ otp = ROTP::OTP.new(otp_column)
123
+ backup_codes = Array.new(self.class.otp_backup_codes_count) do
124
+ otp.generate_otp((SecureRandom.random_number(9e5) + 1e5).to_i)
125
+ end
126
+
127
+ public_send("#{self.class.otp_backup_codes_column_name}=", backup_codes)
128
+ end
129
+
130
+ def backup_codes_enabled?
131
+ self.class.attribute_method?(self.class.otp_backup_codes_column_name)
132
+ end
133
+
134
+ private
135
+
136
+ def authenticate_hotp(code, options = {})
137
+ hotp = ROTP::HOTP.new(otp_column, digits: otp_digits)
138
+ result = hotp.verify(code, otp_counter)
139
+ if result && options[:auto_increment]
140
+ self.otp_counter += 1
141
+ save if respond_to?(:changed?) && !new_record?
142
+ end
143
+ result
144
+ end
145
+
146
+ def authenticate_totp(code, options = {})
147
+ totp = ROTP::TOTP.new(otp_column, digits: otp_digits)
148
+ if (drift = options[:drift])
149
+ totp.verify(code, drift_behind: drift)
150
+ else
151
+ totp.verify(code)
152
+ end
153
+ end
154
+
155
+ def hotp_code(options = {})
156
+ if options[:auto_increment]
157
+ self.otp_counter += 1
158
+ save if respond_to?(:changed?) && !new_record?
159
+ end
160
+ ROTP::HOTP.new(otp_column, digits: otp_digits).at(otp_counter)
161
+ end
162
+
163
+ def totp_code(options = {})
164
+ time = if options.is_a?(Hash)
165
+ options.fetch(:time, Time.now)
166
+ else
167
+ options
168
+ end
169
+ ROTP::TOTP.new(otp_column, digits: otp_digits).at(time)
170
+ end
171
+
172
+ def authenticate_backup_code(code)
173
+ backup_codes_column_name = self.class.otp_backup_codes_column_name
174
+ backup_codes = public_send(backup_codes_column_name)
175
+ return false unless backup_codes.present? && backup_codes.include?(code)
176
+
177
+ if self.class.otp_one_time_backup_codes
178
+ backup_codes.delete(code)
179
+ public_send("#{backup_codes_column_name}=", backup_codes)
180
+ save if respond_to?(:changed?) && !new_record?
181
+ end
182
+
183
+ true
63
184
  end
64
185
  end
65
186
  end
@@ -1,5 +1,5 @@
1
1
  module ActiveModel
2
2
  module Otp
3
- VERSION = "1.1.0"
3
+ VERSION = "2.2.0".freeze
4
4
  end
5
5
  end
@@ -0,0 +1,3 @@
1
+ class ActiverecordUser < ActiveRecord::Base
2
+ has_one_time_password counter_based: true
3
+ end
@@ -0,0 +1,10 @@
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
@@ -0,0 +1,16 @@
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
data/test/models/user.rb CHANGED
@@ -1,10 +1,15 @@
1
1
  class User
2
2
  extend ActiveModel::Callbacks
3
+ include ActiveModel::Serializers::JSON
3
4
  include ActiveModel::Validations
4
5
  include ActiveModel::OneTimePassword
5
6
 
6
7
  define_model_callbacks :create
7
- attr_accessor :otp_secret_key, :email
8
+ attr_accessor :otp_secret_key, :otp_backup_codes, :email
8
9
 
9
- has_one_time_password
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
10
15
  end
@@ -1,6 +1,8 @@
1
- require "test_helper"
1
+ # frozen_string_literal: true
2
2
 
3
- class OtpTest < MiniTest::Unit::TestCase
3
+ require 'test_helper'
4
+
5
+ class OtpTest < MiniTest::Test
4
6
  def setup
5
7
  @user = User.new
6
8
  @user.email = 'roberto@heapsource.com'
@@ -9,23 +11,79 @@ class OtpTest < MiniTest::Unit::TestCase
9
11
  @visitor = Visitor.new
10
12
  @visitor.email = 'roberto@heapsource.com'
11
13
  @visitor.run_callbacks :create
14
+
15
+ @member = Member.new
16
+ @member.email = nil
17
+ @member.run_callbacks :create
18
+
19
+ @ar_user = ActiverecordUser.new
20
+ @ar_user.email = 'roberto@heapsource.com'
21
+ @ar_user.run_callbacks :create
22
+
23
+ @opt_in = OptInTwoFactor.new
24
+ @opt_in.email = 'roberto@heapsource.com'
25
+ @opt_in.run_callbacks :create
12
26
  end
13
27
 
14
28
  def test_authenticate_with_otp
15
29
  code = @user.otp_code
16
-
17
30
  assert @user.authenticate_otp(code)
18
31
 
19
32
  code = @visitor.otp_code
20
33
  assert @visitor.authenticate_otp(code)
21
34
  end
22
35
 
36
+ def test_counter_based_otp
37
+ code = @member.otp_code
38
+ assert @member.authenticate_otp(code)
39
+ assert @member.authenticate_otp(code, auto_increment: true)
40
+ assert !@member.authenticate_otp(code)
41
+ @member.otp_counter -= 1
42
+ assert @member.authenticate_otp(code)
43
+ assert code == @member.otp_code
44
+ assert code != @member.otp_code(auto_increment: true)
45
+ end
46
+
47
+ def test_counter_based_otp_active_record
48
+ code = @ar_user.otp_code
49
+ assert @ar_user.authenticate_otp(code)
50
+ assert @ar_user.authenticate_otp(code, auto_increment: true)
51
+ assert !@ar_user.authenticate_otp(code)
52
+ @ar_user.otp_counter -= 1
53
+ assert @ar_user.authenticate_otp(code)
54
+ assert code == @ar_user.otp_code
55
+ assert code != @ar_user.otp_code(auto_increment: true)
56
+ end
57
+
58
+ def test_opt_in_two_factor
59
+ assert @opt_in.otp_column.nil?
60
+
61
+ @opt_in.otp_regenerate_secret
62
+ code = @opt_in.otp_code
63
+ assert_equal true, @opt_in.authenticate_otp(code)
64
+ end
65
+
23
66
  def test_authenticate_with_otp_when_drift_is_allowed
24
67
  code = @user.otp_code(Time.now - 30)
25
- assert @user.authenticate_otp(code, drift: 60)
68
+ assert_equal true, @user.authenticate_otp(code, drift: 60)
26
69
 
27
70
  code = @visitor.otp_code(Time.now - 30)
28
- assert @visitor.authenticate_otp(code, drift: 60)
71
+ assert_equal true, @visitor.authenticate_otp(code, drift: 60)
72
+ end
73
+
74
+ def test_authenticate_with_backup_code
75
+ backup_code = @user.public_send(@user.otp_backup_codes_column_name).first
76
+ assert_equal true, @user.authenticate_otp(backup_code)
77
+
78
+ backup_code = @user.public_send(@user.otp_backup_codes_column_name).last
79
+ @user.otp_regenerate_backup_codes
80
+ assert_equal true, !@user.authenticate_otp(backup_code)
81
+ end
82
+
83
+ def test_authenticate_with_one_time_backup_code
84
+ backup_code = @user.public_send(@user.otp_backup_codes_column_name).first
85
+ assert_equal true, @user.authenticate_otp(backup_code)
86
+ assert_equal true, !@user.authenticate_otp(backup_code)
29
87
  end
30
88
 
31
89
  def test_otp_code
@@ -34,31 +92,61 @@ class OtpTest < MiniTest::Unit::TestCase
34
92
  end
35
93
 
36
94
  def test_otp_code_with_specific_length
37
- assert_match(/^\d{4}$/, @visitor.otp_code(time: 2160, padding: true).to_s)
38
- assert_operator(@visitor.otp_code(time: 2160, padding: false).to_s.length, :<= , 4)
95
+ assert_match(/^\d{4}$/, @visitor.otp_code(2160).to_s)
96
+ assert_operator(@visitor.otp_code(2160).to_s.length, :<=, 4)
39
97
  end
40
98
 
41
99
  def test_otp_code_without_specific_length
42
- assert_match(/^\d{6}$/, @user.otp_code(time: 2160, padding: true).to_s)
43
- assert_operator(@user.otp_code(time: 2160, padding: false).to_s.length, :<= , 6)
44
- end
45
-
46
- def test_otp_code_padding
47
- @user.otp_column = 'kw5jhligwqaiw7jc'
48
- assert_match(/^\d{6}$/, @user.otp_code(time: 2160, padding: true).to_s)
49
- # Modified this spec as it is not guranteed that without padding we will always
50
- # get a 3 digit number
51
- assert_operator(@user.otp_code(time: 2160, padding: false).to_s.length, :<= , 6)
100
+ assert_match(/^\d{6}$/, @user.otp_code(2160).to_s)
101
+ assert_operator(@user.otp_code(2160).to_s.length, :<=, 6)
52
102
  end
53
103
 
54
104
  def test_provisioning_uri_with_provided_account
55
- assert_match %r{otpauth://totp/roberto\?secret=\w{16}}, @user.provisioning_uri("roberto")
56
- assert_match %r{otpauth://totp/roberto\?secret=\w{16}}, @visitor.provisioning_uri("roberto")
105
+ totp = %r{^otpauth://totp/roberto\?secret=\w{32}$}
106
+ hotp = %r{^otpauth://hotp/roberto\?secret=\w{32}&counter=1$}
107
+
108
+ assert_match totp, @user.provisioning_uri('roberto')
109
+ assert_match totp, @visitor.provisioning_uri('roberto')
110
+ assert_match hotp, @member.provisioning_uri('roberto')
57
111
  end
58
112
 
59
113
  def test_provisioning_uri_with_email_field
60
- assert_match %r{otpauth://totp/roberto@heapsource\.com\?secret=\w{16}}, @user.provisioning_uri
61
- assert_match %r{otpauth://totp/roberto@heapsource\.com\?secret=\w{16}}, @visitor.provisioning_uri
114
+ totp = %r{^otpauth://totp/roberto%40heapsource\.com\?secret=\w{32}$}
115
+ hotp = %r{^otpauth://hotp/\?secret=\w{32}&counter=1$}
116
+
117
+ assert_match totp, @user.provisioning_uri
118
+ assert_match totp, @visitor.provisioning_uri
119
+ assert_match hotp, @member.provisioning_uri
120
+ end
121
+
122
+ def test_provisioning_uri_with_options
123
+ account = %r{
124
+ ^otpauth://totp/Example\:roberto\?secret=\w{32}&issuer=Example$
125
+ }x
126
+
127
+ email = %r{
128
+ ^otpauth://totp/Example\:roberto%40heapsource\.com\?secret=\w{32}
129
+ &issuer=Example$
130
+ }x
131
+
132
+ assert_match(
133
+ account, @user.provisioning_uri('roberto', issuer: 'Example')
134
+ )
135
+
136
+ assert_match(
137
+ account, @visitor.provisioning_uri('roberto', issuer: 'Example')
138
+ )
139
+
140
+ assert_match email, @user.provisioning_uri(nil, issuer: 'Example')
141
+ assert_match email, @visitor.provisioning_uri(nil, issuer: 'Example')
142
+ end
143
+
144
+ def test_provisioning_uri_with_incremented_counter
145
+ 2.times { @member.otp_code(auto_increment: true) }
146
+
147
+ hotp = %r{^otpauth://hotp/\?secret=\w{32}&counter=3$}
148
+
149
+ assert_match hotp, @member.provisioning_uri
62
150
  end
63
151
 
64
152
  def test_regenerate_otp
@@ -66,4 +154,12 @@ class OtpTest < MiniTest::Unit::TestCase
66
154
  @user.otp_regenerate_secret
67
155
  assert secret != @user.otp_column
68
156
  end
157
+
158
+ def test_hide_secret_key_in_serialize
159
+ refute_match(/otp_secret_key/, @user.to_json)
160
+ end
161
+
162
+ def test_otp_random_secret
163
+ assert_match(/^.{32}$/, @user.class.otp_random_secret)
164
+ end
69
165
  end
data/test/schema.rb ADDED
@@ -0,0 +1,11 @@
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
+ end
data/test/test_helper.rb CHANGED
@@ -8,5 +8,14 @@ require "rubygems"
8
8
  require "active_model_otp"
9
9
  require "minitest/autorun"
10
10
  require "minitest/unit"
11
+ require "active_record"
12
+
13
+ begin
14
+ require "activemodel-serializers-xml"
15
+ rescue LoadError
16
+ end
17
+
18
+ ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:"
19
+ load "#{ File.dirname(__FILE__) }/schema.rb"
11
20
 
12
21
  Dir["models/*.rb"].each {|file| require file }
metadata CHANGED
@@ -1,16 +1,16 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_model_otp
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Guillermo Iguaran
8
8
  - Roberto Miranda
9
9
  - Heapsource
10
- autorequire:
10
+ autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2014-10-10 00:00:00.000000000 Z
13
+ date: 2021-05-30 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: activemodel
@@ -30,30 +30,30 @@ dependencies:
30
30
  name: rotp
31
31
  requirement: !ruby/object:Gem::Requirement
32
32
  requirements:
33
- - - ">="
33
+ - - "~>"
34
34
  - !ruby/object:Gem::Version
35
- version: '0'
35
+ version: 6.2.0
36
36
  type: :runtime
37
37
  prerelease: false
38
38
  version_requirements: !ruby/object:Gem::Requirement
39
39
  requirements:
40
- - - ">="
40
+ - - "~>"
41
41
  - !ruby/object:Gem::Version
42
- version: '0'
42
+ version: 6.2.0
43
43
  - !ruby/object:Gem::Dependency
44
- name: bundler
44
+ name: activerecord
45
45
  requirement: !ruby/object:Gem::Requirement
46
46
  requirements:
47
- - - "~>"
47
+ - - ">="
48
48
  - !ruby/object:Gem::Version
49
- version: '1.3'
49
+ version: '0'
50
50
  type: :development
51
51
  prerelease: false
52
52
  version_requirements: !ruby/object:Gem::Requirement
53
53
  requirements:
54
- - - "~>"
54
+ - - ">="
55
55
  - !ruby/object:Gem::Version
56
- version: '1.3'
56
+ version: '0'
57
57
  - !ruby/object:Gem::Dependency
58
58
  name: rake
59
59
  requirement: !ruby/object:Gem::Requirement
@@ -82,8 +82,36 @@ dependencies:
82
82
  - - "~>"
83
83
  - !ruby/object:Gem::Version
84
84
  version: 5.4.2
85
- description: Adds methods to set and authenticate against one time passwords. Inspired
86
- in AM::SecurePassword"
85
+ - !ruby/object:Gem::Dependency
86
+ name: appraisal
87
+ requirement: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ type: :development
93
+ prerelease: false
94
+ version_requirements: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: '0'
99
+ - !ruby/object:Gem::Dependency
100
+ name: sqlite3
101
+ requirement: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: '0'
106
+ type: :development
107
+ prerelease: false
108
+ version_requirements: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: '0'
113
+ description: Adds methods to set and authenticate against one time passwords 2FA(Two
114
+ factor Authentication). Inspired in AM::SecurePassword"
87
115
  email:
88
116
  - guilleiguaran@gmail.com
89
117
  - rjmaltamar@gmail.com
@@ -94,24 +122,35 @@ extra_rdoc_files: []
94
122
  files:
95
123
  - ".gitignore"
96
124
  - ".travis.yml"
125
+ - Appraisals
97
126
  - CHANGELOG.md
98
127
  - Gemfile
99
128
  - LICENSE.txt
100
129
  - README.md
101
130
  - Rakefile
102
131
  - active_model_otp.gemspec
132
+ - gemfiles/rails_4.2.gemfile
133
+ - gemfiles/rails_5.0.gemfile
134
+ - gemfiles/rails_5.1.gemfile
135
+ - gemfiles/rails_5.2.gemfile
136
+ - gemfiles/rails_6.0.gemfile
137
+ - gemfiles/rails_6.1.gemfile
103
138
  - lib/active_model/one_time_password.rb
104
139
  - lib/active_model/otp/version.rb
105
140
  - lib/active_model_otp.rb
141
+ - test/models/activerecord_user.rb
142
+ - test/models/member.rb
143
+ - test/models/opt_in_two_factor.rb
106
144
  - test/models/user.rb
107
145
  - test/models/visitor.rb
108
146
  - test/one_time_password_test.rb
147
+ - test/schema.rb
109
148
  - test/test_helper.rb
110
149
  homepage: ''
111
150
  licenses:
112
151
  - MIT
113
152
  metadata: {}
114
- post_install_message:
153
+ post_install_message:
115
154
  rdoc_options: []
116
155
  require_paths:
117
156
  - lib
@@ -119,20 +158,23 @@ required_ruby_version: !ruby/object:Gem::Requirement
119
158
  requirements:
120
159
  - - ">="
121
160
  - !ruby/object:Gem::Version
122
- version: '0'
161
+ version: '2.3'
123
162
  required_rubygems_version: !ruby/object:Gem::Requirement
124
163
  requirements:
125
164
  - - ">="
126
165
  - !ruby/object:Gem::Version
127
166
  version: '0'
128
167
  requirements: []
129
- rubyforge_project:
130
- rubygems_version: 2.2.2
131
- signing_key:
168
+ rubygems_version: 3.0.3
169
+ signing_key:
132
170
  specification_version: 4
133
171
  summary: Adds methods to set and authenticate against one time passwords.
134
172
  test_files:
173
+ - test/models/activerecord_user.rb
174
+ - test/models/member.rb
175
+ - test/models/opt_in_two_factor.rb
135
176
  - test/models/user.rb
136
177
  - test/models/visitor.rb
137
178
  - test/one_time_password_test.rb
179
+ - test/schema.rb
138
180
  - test/test_helper.rb