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