active_model_otp 2.3.1 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 906ff23803a070afb3df376eb45d4314640c4f4012028d7b7bfc16d9263def54
4
- data.tar.gz: efde68de226fb2d7a5b2230fc19958502e678776781419ff89532d19d5e9682e
3
+ metadata.gz: 98c3fad16e797e0483e6aeebcdae72e868dd699b82f07400ad0ee9b0261dbd2a
4
+ data.tar.gz: 3454ea7c190b0f7f69b50cf617c132a46e1a22f7f5554e4159acec61dd40979f
5
5
  SHA512:
6
- metadata.gz: 2805bf0a8dc09e6699b9617b60f662b4ff50c82c4f952d9e0fdc2a9c2c6c5a5829ea42ceb9bb2167d64d83c050a79272b3224cc0e21ecb93069fde5cc826f9ba
7
- data.tar.gz: c893d4e40737f2a724e912dd84edfc1cbd3886515757596582436d2694d2ab4836ae7251a522e66c3ac95f31fef4c52f1d1f47836d09d39cb7d259c6c335c77d
6
+ metadata.gz: 93a2cec634c0b00a4480598f0dc5fe8bfcf27a9dcc35fb6603eb7bbdfb90fc4f85f94443fbe4b9df9acb6113facd7c14bc12368f0ad15f4df2c0cd5b5498f6bf
7
+ data.tar.gz: 232296291239477d07f49ba75bc9db6288a8a3e039f6b1bc93bb3f9ce57d07d8e3624450ebc425e4b7532073f5937506f97f67ec014cc9c6e2312c30aeeb755e
@@ -9,22 +9,48 @@ on:
9
9
  jobs:
10
10
  ci:
11
11
  runs-on: ubuntu-latest
12
-
12
+
13
13
  strategy:
14
14
  matrix:
15
- gemfile: [rails_4.2, rails_5.0, rails_5.1, rails_5.2, rails_6.0, rails_6.1]
16
- ruby-version: [2.3, 2.4, 2.5, 2.6, 2.7, 3.0]
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
17
  exclude:
18
- - { gemfile: rails_6.0, ruby-version: 2.3 }
19
- - { gemfile: rails_6.1, ruby-version: 2.3 }
20
- - { gemfile: rails_6.0, ruby-version: 2.4 }
21
- - { gemfile: rails_6.1, ruby-version: 2.4 }
18
+ - { gemfile: rails_4.2, ruby-version: 2.5 }
19
+ - { gemfile: rails_4.2, ruby-version: 2.6 }
22
20
  - { gemfile: rails_4.2, ruby-version: 2.7 }
23
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 }
24
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 }
25
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 }
26
36
  - { gemfile: rails_5.2, ruby-version: 3.0 }
27
-
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
+
28
54
  env:
29
55
  BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile
30
56
 
data/Appraisals CHANGED
@@ -34,3 +34,10 @@ appraise "rails-6.1" do
34
34
  gem "activemodel-serializers-xml"
35
35
  gem "sqlite3", "~> 1.4"
36
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
data/README.md CHANGED
@@ -98,9 +98,31 @@ sleep 30 # lets wait again
98
98
  user.authenticate_otp('186522', drift: 60) # => true
99
99
  ```
100
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
+
101
123
  ## Counter based OTP
102
124
 
103
- An additonal counter field is required in our ``User`` Model
125
+ An additional counter field is required in our ``User`` Model
104
126
 
105
127
  ```ruby
106
128
  rails g migration AddCounterForOtpToUsers otp_counter:integer
@@ -213,7 +235,7 @@ user.provisioning_uri(nil, issuer: 'MYAPP') #=> 'otpauth://totp/hello@heapsource
213
235
 
214
236
  This can then be rendered as a QR Code which can be scanned and added to the users list of OTP credentials.
215
237
 
216
- ### Setting up a customer interval
238
+ ### Setting up a customer interval
217
239
 
218
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)
219
241
 
@@ -0,0 +1,10 @@
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: "../"
@@ -13,32 +13,20 @@ module ActiveModel
13
13
  module ClassMethods
14
14
  def has_one_time_password(options = {})
15
15
  cattr_accessor :otp_column_name, :otp_counter_column_name,
16
- :otp_backup_codes_column_name
16
+ :otp_backup_codes_column_name, :otp_after_column_name
17
17
  class_attribute :otp_digits, :otp_counter_based,
18
18
  :otp_backup_codes_count, :otp_one_time_backup_codes,
19
19
  :otp_interval
20
20
 
21
- self.otp_column_name = (
22
- options[:column_name] || OTP_DEFAULT_COLUMN_NAME
23
- ).to_s
21
+ self.otp_column_name = (options[:column_name] || OTP_DEFAULT_COLUMN_NAME).to_s
24
22
  self.otp_digits = options[:length] || OTP_DEFAULT_DIGITS
25
- self.otp_counter_based = (
26
- options[:counter_based] || OTP_COUNTER_ENABLED_BY_DEFAULT
27
- )
28
- self.otp_counter_column_name = (
29
- options[:counter_column_name] || OTP_DEFAULT_COUNTER_COLUMN_NAME
30
- ).to_s
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
31
25
  self.otp_interval = options[:interval]
32
- self.otp_backup_codes_column_name = (
33
- options[:backup_codes_column_name] ||
34
- OTP_DEFAULT_BACKUP_CODES_COLUMN_NAME
35
- ).to_s
36
- self.otp_backup_codes_count = (
37
- options[:backup_codes_count] || OTP_DEFAULT_BACKUP_CODES_COUNT
38
- )
39
- self.otp_one_time_backup_codes = (
40
- options[:one_time_backup_codes] || OTP_BACKUP_CODES_ENABLED_BY_DEFAULT
41
- )
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
42
30
 
43
31
  include InstanceMethodsOnActivation
44
32
 
@@ -95,13 +83,9 @@ module ActiveModel
95
83
  account ||= ""
96
84
 
97
85
  if otp_counter_based
98
- ROTP::HOTP
99
- .new(otp_column, options)
100
- .provisioning_uri(account, self.otp_counter)
86
+ ROTP::HOTP.new(otp_column, options).provisioning_uri(account, self.otp_counter)
101
87
  else
102
- ROTP::TOTP
103
- .new(otp_column, options)
104
- .provisioning_uri(account)
88
+ ROTP::TOTP.new(otp_column, options).provisioning_uri(account)
105
89
  end
106
90
  end
107
91
 
@@ -133,6 +117,7 @@ module ActiveModel
133
117
  options ||= {}
134
118
  options[:except] = Array(options[:except])
135
119
  options[:except] << self.class.otp_column_name
120
+
136
121
  super(options)
137
122
  end
138
123
 
@@ -154,45 +139,42 @@ module ActiveModel
154
139
  def authenticate_hotp(code, options = {})
155
140
  hotp = ROTP::HOTP.new(otp_column, digits: otp_digits)
156
141
  result = hotp.verify(code, otp_counter)
142
+
157
143
  if result && options[:auto_increment]
158
144
  self.otp_counter += 1
159
145
  save if respond_to?(:changed?) && !new_record?
160
146
  end
147
+
161
148
  result
162
149
  end
163
150
 
164
151
  def authenticate_totp(code, options = {})
165
- totp = ROTP::TOTP.new(
166
- otp_column,
167
- digits: otp_digits,
168
- interval: otp_interval
169
- )
170
- if (drift = options[:drift])
171
- totp.verify(code, drift_behind: drift)
172
- else
173
- totp.verify(code)
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)
174
158
  end
175
159
  end
176
160
 
161
+ def otp_after_column_name_enabled?
162
+ otp_after_column_name && respond_to?(otp_after_column_name)
163
+ end
164
+
177
165
  def hotp_code(options = {})
178
166
  if options[:auto_increment]
179
167
  self.otp_counter += 1
180
168
  save if respond_to?(:changed?) && !new_record?
181
169
  end
170
+
182
171
  ROTP::HOTP.new(otp_column, digits: otp_digits).at(otp_counter)
183
172
  end
184
173
 
185
174
  def totp_code(options = {})
186
- time = if options.is_a?(Hash)
187
- options.fetch(:time, Time.now)
188
- else
189
- options
190
- end
191
- ROTP::TOTP.new(
192
- otp_column,
193
- digits: otp_digits,
194
- interval: otp_interval
195
- ).at(time)
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)
196
178
  end
197
179
 
198
180
  def authenticate_backup_code(code)
@@ -1,5 +1,5 @@
1
1
  module ActiveModel
2
2
  module Otp
3
- VERSION = '2.3.1'.freeze
3
+ VERSION = '2.3.2'.freeze
4
4
  end
5
5
  end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AfterUser < ActiveRecord::Base
4
+ has_one_time_password after_column_name: :last_otp_at
5
+ end
@@ -3,6 +3,8 @@
3
3
  require 'test_helper'
4
4
 
5
5
  class OtpTest < MiniTest::Test
6
+ include ActiveSupport::Testing::TimeHelpers
7
+
6
8
  def setup
7
9
  @user = User.new
8
10
  @user.email = 'roberto@heapsource.com'
@@ -23,6 +25,10 @@ class OtpTest < MiniTest::Test
23
25
  @opt_in = OptInTwoFactor.new
24
26
  @opt_in.email = 'roberto@heapsource.com'
25
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
26
32
  end
27
33
 
28
34
  def test_authenticate_with_otp
@@ -88,6 +94,18 @@ class OtpTest < MiniTest::Test
88
94
  assert_equal true, @visitor.authenticate_otp(code, drift: 60)
89
95
  end
90
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
+
91
109
  def test_authenticate_with_backup_code
92
110
  backup_code = @user.public_send(@user.otp_backup_codes_column_name).first
93
111
  assert_equal true, @user.authenticate_otp(backup_code)
@@ -115,7 +133,7 @@ class OtpTest < MiniTest::Test
115
133
 
116
134
  def test_otp_code_without_specific_length
117
135
  assert_match(/^\d{6}$/, @user.otp_code(2160).to_s)
118
- assert_operator(@user.otp_code(2160).to_s.length, :<=, 6)
136
+ assert @user.otp_code(2160).to_s.length <= 6
119
137
  end
120
138
 
121
139
  def test_provisioning_uri_with_provided_account
@@ -146,13 +164,8 @@ class OtpTest < MiniTest::Test
146
164
  &issuer=Example$
147
165
  }x
148
166
 
149
- assert_match(
150
- account, @user.provisioning_uri('roberto', issuer: 'Example')
151
- )
152
-
153
- assert_match(
154
- account, @visitor.provisioning_uri('roberto', issuer: 'Example')
155
- )
167
+ assert_match account, @user.provisioning_uri('roberto', issuer: 'Example')
168
+ assert_match account, @visitor.provisioning_uri('roberto', issuer: 'Example')
156
169
 
157
170
  assert_match email, @user.provisioning_uri(nil, issuer: 'Example')
158
171
  assert_match email, @visitor.provisioning_uri(nil, issuer: 'Example')
@@ -186,8 +199,10 @@ class OtpTest < MiniTest::Test
186
199
  @interval_user.run_callbacks :create
187
200
  otp_code = @interval_user.otp_code
188
201
  2.times { assert_match(otp_code, @interval_user.otp_code) }
189
- sleep 5
190
- refute_match(otp_code, @interval_user.otp_code)
202
+
203
+ travel 5.seconds do
204
+ refute_match(otp_code, @interval_user.otp_code)
205
+ end
191
206
  end
192
207
 
193
208
  def test_otp_default_interval
@@ -195,8 +210,11 @@ class OtpTest < MiniTest::Test
195
210
  @default_interval_user.email = 'roberto@heapsource.com'
196
211
  @default_interval_user.run_callbacks :create
197
212
  otp_code = @default_interval_user.otp_code
213
+
198
214
  2.times { assert_match(otp_code, @default_interval_user.otp_code) }
199
- sleep 5
200
- 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
201
219
  end
202
220
  end
data/test/schema.rb CHANGED
@@ -24,4 +24,13 @@ ActiveRecord::Schema.define do
24
24
  t.string :otp_secret_key
25
25
  t.timestamps
26
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
27
36
  end
data/test/test_helper.rb CHANGED
@@ -9,6 +9,7 @@ require "active_model_otp"
9
9
  require "minitest/autorun"
10
10
  require "minitest/unit"
11
11
  require "active_record"
12
+ require "active_support/testing/time_helpers"
12
13
 
13
14
  begin
14
15
  require "activemodel-serializers-xml"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_model_otp
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.3.1
4
+ version: 2.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Guillermo Iguaran
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2021-10-22 00:00:00.000000000 Z
13
+ date: 2023-04-26 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: activemodel
@@ -135,10 +135,12 @@ files:
135
135
  - gemfiles/rails_5.2.gemfile
136
136
  - gemfiles/rails_6.0.gemfile
137
137
  - gemfiles/rails_6.1.gemfile
138
+ - gemfiles/rails_7.0.gemfile
138
139
  - lib/active_model/one_time_password.rb
139
140
  - lib/active_model/otp/version.rb
140
141
  - lib/active_model_otp.rb
141
142
  - test/models/activerecord_user.rb
143
+ - test/models/after_user.rb
142
144
  - test/models/default_interval_user.rb
143
145
  - test/models/interval_user.rb
144
146
  - test/models/member.rb
@@ -167,12 +169,13 @@ required_rubygems_version: !ruby/object:Gem::Requirement
167
169
  - !ruby/object:Gem::Version
168
170
  version: '0'
169
171
  requirements: []
170
- rubygems_version: 3.0.3
172
+ rubygems_version: 3.4.1
171
173
  signing_key:
172
174
  specification_version: 4
173
175
  summary: Adds methods to set and authenticate against one time passwords.
174
176
  test_files:
175
177
  - test/models/activerecord_user.rb
178
+ - test/models/after_user.rb
176
179
  - test/models/default_interval_user.rb
177
180
  - test/models/interval_user.rb
178
181
  - test/models/member.rb