active_model_otp 2.3.1 → 2.3.2

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
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