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 +4 -4
- data/.github/workflows/active_model_otp.yml +34 -8
- data/Appraisals +7 -0
- data/README.md +24 -2
- data/gemfiles/rails_7.0.gemfile +10 -0
- data/lib/active_model/one_time_password.rb +27 -45
- data/lib/active_model/otp/version.rb +1 -1
- data/test/models/after_user.rb +5 -0
- data/test/one_time_password_test.rb +30 -12
- data/test/schema.rb +9 -0
- data/test/test_helper.rb +1 -0
- metadata +6 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 98c3fad16e797e0483e6aeebcdae72e868dd699b82f07400ad0ee9b0261dbd2a
|
4
|
+
data.tar.gz: 3454ea7c190b0f7f69b50cf617c132a46e1a22f7f5554e4159acec61dd40979f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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:
|
19
|
-
- { gemfile:
|
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
|
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
|
|
@@ -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
|
-
|
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.
|
33
|
-
|
34
|
-
|
35
|
-
|
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
|
-
|
167
|
-
|
168
|
-
|
169
|
-
)
|
170
|
-
|
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 =
|
187
|
-
|
188
|
-
|
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)
|
@@ -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
|
-
|
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
|
-
|
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
|
-
|
190
|
-
|
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
|
-
|
200
|
-
|
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
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.
|
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:
|
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.
|
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
|