active_model_otp 2.1.0 → 2.3.1
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 +43 -0
- data/Appraisals +11 -0
- data/README.md +69 -2
- data/active_model_otp.gemspec +3 -3
- data/gemfiles/rails_4.2.gemfile +1 -0
- data/gemfiles/rails_5.0.gemfile +1 -0
- data/gemfiles/rails_5.1.gemfile +1 -0
- data/gemfiles/rails_5.2.gemfile +1 -0
- data/gemfiles/rails_6.1.gemfile +10 -0
- data/lib/active_model/one_time_password.rb +123 -35
- data/lib/active_model/otp/version.rb +1 -1
- data/test/models/default_interval_user.rb +5 -0
- data/test/models/interval_user.rb +5 -0
- data/test/models/user.rb +3 -3
- data/test/one_time_password_test.rb +99 -17
- data/test/schema.rb +16 -0
- metadata +14 -9
- data/.travis.yml +0 -26
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 906ff23803a070afb3df376eb45d4314640c4f4012028d7b7bfc16d9263def54
|
4
|
+
data.tar.gz: efde68de226fb2d7a5b2230fc19958502e678776781419ff89532d19d5e9682e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2805bf0a8dc09e6699b9617b60f662b4ff50c82c4f952d9e0fdc2a9c2c6c5a5829ea42ceb9bb2167d64d83c050a79272b3224cc0e21ecb93069fde5cc826f9ba
|
7
|
+
data.tar.gz: c893d4e40737f2a724e912dd84edfc1cbd3886515757596582436d2694d2ab4836ae7251a522e66c3ac95f31fef4c52f1d1f47836d09d39cb7d259c6c335c77d
|
@@ -0,0 +1,43 @@
|
|
1
|
+
name: Active Model OTP
|
2
|
+
|
3
|
+
on:
|
4
|
+
push:
|
5
|
+
branches: [main]
|
6
|
+
pull_request:
|
7
|
+
types: [opened, synchronize, reopened, edited]
|
8
|
+
|
9
|
+
jobs:
|
10
|
+
ci:
|
11
|
+
runs-on: ubuntu-latest
|
12
|
+
|
13
|
+
strategy:
|
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]
|
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 }
|
22
|
+
- { gemfile: rails_4.2, ruby-version: 2.7 }
|
23
|
+
- { gemfile: rails_4.2, ruby-version: 3.0 }
|
24
|
+
- { gemfile: rails_5.0, ruby-version: 3.0 }
|
25
|
+
- { gemfile: rails_5.1, ruby-version: 3.0 }
|
26
|
+
- { gemfile: rails_5.2, ruby-version: 3.0 }
|
27
|
+
|
28
|
+
env:
|
29
|
+
BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile
|
30
|
+
|
31
|
+
steps:
|
32
|
+
- uses: actions/checkout@v2
|
33
|
+
|
34
|
+
- name: Install Ruby ${{ matrix.ruby-version }}
|
35
|
+
uses: ruby/setup-ruby@v1
|
36
|
+
with:
|
37
|
+
ruby-version: ${{ matrix.ruby-version }}
|
38
|
+
|
39
|
+
- name: Install dependencies
|
40
|
+
run: bundle install
|
41
|
+
|
42
|
+
- name: Run tests with Ruby ${{ matrix.ruby-version }} and Gemfile ${{ matrix.gemfile }}
|
43
|
+
run: bundle exec rake
|
data/Appraisals
CHANGED
@@ -1,20 +1,24 @@
|
|
1
1
|
appraise "rails-4.2" do
|
2
2
|
gem "activemodel", "~> 4.2"
|
3
|
+
gem "sqlite3", "~> 1.3.6"
|
3
4
|
end
|
4
5
|
|
5
6
|
appraise "rails-5.0" do
|
6
7
|
gem "activemodel", "~> 5.0"
|
7
8
|
gem "activemodel-serializers-xml"
|
9
|
+
gem "sqlite3", "~> 1.3.6"
|
8
10
|
end
|
9
11
|
|
10
12
|
appraise "rails-5.1" do
|
11
13
|
gem "activemodel", "~> 5.1"
|
12
14
|
gem "activemodel-serializers-xml"
|
15
|
+
gem "sqlite3", "~> 1.3.6"
|
13
16
|
end
|
14
17
|
|
15
18
|
appraise "rails-5.2" do
|
16
19
|
gem "activemodel", "~> 5.2"
|
17
20
|
gem "activemodel-serializers-xml"
|
21
|
+
gem "sqlite3", "~> 1.3.6"
|
18
22
|
end
|
19
23
|
|
20
24
|
appraise "rails-6.0" do
|
@@ -23,3 +27,10 @@ appraise "rails-6.0" do
|
|
23
27
|
gem "activemodel-serializers-xml"
|
24
28
|
gem "sqlite3", "~> 1.4"
|
25
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/README.md
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
[![
|
1
|
+
[![Active Model OTP](https://github.com/heapsource/active_model_otp/actions/workflows/active_model_otp.yml/badge.svg?branch=main)](https://github.com/heapsource/active_model_otp/actions/workflows/active_model_otp.yml)
|
2
2
|
[![Gem Version](https://badge.fury.io/rb/active_model_otp.svg)](http://badge.fury.io/rb/active_model_otp)
|
3
3
|
[![Reviewed by Hound](https://img.shields.io/badge/Reviewed_by-Hound-8E64B0.svg)](https://houndci.com)
|
4
4
|
|
@@ -9,7 +9,7 @@
|
|
9
9
|
|
10
10
|
## Dependencies
|
11
11
|
|
12
|
-
* [ROTP](https://github.com/mdp/rotp)
|
12
|
+
* [ROTP](https://github.com/mdp/rotp) 6.2.0 or higher
|
13
13
|
* Ruby 2.3 or greater
|
14
14
|
|
15
15
|
## Installation
|
@@ -150,6 +150,51 @@ user.otp_code(auto_increment: true) # => '002811'
|
|
150
150
|
user.otp_code # => '002811'
|
151
151
|
```
|
152
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
|
+
|
153
198
|
## Google Authenticator Compatible
|
154
199
|
|
155
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.
|
@@ -168,6 +213,28 @@ user.provisioning_uri(nil, issuer: 'MYAPP') #=> 'otpauth://totp/hello@heapsource
|
|
168
213
|
|
169
214
|
This can then be rendered as a QR Code which can be scanned and added to the users list of OTP credentials.
|
170
215
|
|
216
|
+
### Setting up a customer interval
|
217
|
+
|
218
|
+
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
|
+
|
220
|
+
```ruby
|
221
|
+
class User < ApplicationRecord
|
222
|
+
has_one_time_password interval: 10 # the interval value is in seconds
|
223
|
+
end
|
224
|
+
|
225
|
+
user = User.new
|
226
|
+
user.provisioning_uri("hello", interval: 10) # => 'otpauth://totp/hello?secret=2z6hxkdwi3uvrnpn&period=10'
|
227
|
+
|
228
|
+
# This code snippet generates OTP codes that expires every 10 seconds.
|
229
|
+
```
|
230
|
+
|
231
|
+
**Note**: Only some authenticator apps are compatible with custom `period` of tokens, for more details check these links:
|
232
|
+
|
233
|
+
- https://labanskoller.se/blog/2019/07/11/many-common-mobile-authenticator-apps-accept-qr-codes-for-modes-they-dont-support
|
234
|
+
- https://www.ibm.com/docs/en/sva/9.0.7?topic=authentication-configuring-totp-one-time-password-mechanism
|
235
|
+
|
236
|
+
So, be careful and aware when using custom intervals/periods for your TOTP codes beyond the default 30 seconds :)
|
237
|
+
|
171
238
|
### Working example
|
172
239
|
|
173
240
|
Scan the following barcode with your phone, using Google Authenticator
|
data/active_model_otp.gemspec
CHANGED
@@ -17,11 +17,11 @@ Gem::Specification.new do |spec|
|
|
17
17
|
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
18
|
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
19
|
spec.require_paths = ["lib"]
|
20
|
-
|
20
|
+
|
21
21
|
spec.required_ruby_version = ">= 2.3"
|
22
22
|
|
23
23
|
spec.add_dependency "activemodel"
|
24
|
-
spec.add_dependency "rotp", "~>
|
24
|
+
spec.add_dependency "rotp", "~> 6.2.0"
|
25
25
|
|
26
26
|
spec.add_development_dependency "activerecord"
|
27
27
|
spec.add_development_dependency "rake"
|
@@ -31,6 +31,6 @@ Gem::Specification.new do |spec|
|
|
31
31
|
if RUBY_PLATFORM == "java"
|
32
32
|
spec.add_development_dependency "activerecord-jdbcsqlite3-adapter"
|
33
33
|
else
|
34
|
-
spec.add_development_dependency "sqlite3"
|
34
|
+
spec.add_development_dependency "sqlite3"
|
35
35
|
end
|
36
36
|
end
|
data/gemfiles/rails_4.2.gemfile
CHANGED
data/gemfiles/rails_5.0.gemfile
CHANGED
data/gemfiles/rails_5.1.gemfile
CHANGED
data/gemfiles/rails_5.2.gemfile
CHANGED
@@ -2,22 +2,50 @@ module ActiveModel
|
|
2
2
|
module OneTimePassword
|
3
3
|
extend ActiveSupport::Concern
|
4
4
|
|
5
|
+
OTP_DEFAULT_COLUMN_NAME = 'otp_secret_key'.freeze
|
6
|
+
OTP_DEFAULT_COUNTER_COLUMN_NAME = 'otp_counter'.freeze
|
7
|
+
OTP_DEFAULT_BACKUP_CODES_COLUMN_NAME = 'otp_backup_codes'.freeze
|
8
|
+
OTP_DEFAULT_DIGITS = 6
|
9
|
+
OTP_DEFAULT_BACKUP_CODES_COUNT = 12
|
10
|
+
OTP_COUNTER_ENABLED_BY_DEFAULT = false
|
11
|
+
OTP_BACKUP_CODES_ENABLED_BY_DEFAULT = false
|
12
|
+
|
5
13
|
module ClassMethods
|
6
14
|
def has_one_time_password(options = {})
|
7
|
-
cattr_accessor :otp_column_name, :otp_counter_column_name
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
self.
|
14
|
-
|
15
|
+
cattr_accessor :otp_column_name, :otp_counter_column_name,
|
16
|
+
:otp_backup_codes_column_name
|
17
|
+
class_attribute :otp_digits, :otp_counter_based,
|
18
|
+
:otp_backup_codes_count, :otp_one_time_backup_codes,
|
19
|
+
:otp_interval
|
20
|
+
|
21
|
+
self.otp_column_name = (
|
22
|
+
options[:column_name] || OTP_DEFAULT_COLUMN_NAME
|
23
|
+
).to_s
|
24
|
+
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
|
31
|
+
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
|
+
)
|
15
42
|
|
16
43
|
include InstanceMethodsOnActivation
|
17
44
|
|
18
|
-
before_create(options.slice(:if, :unless)) do
|
45
|
+
before_create(**options.slice(:if, :unless)) do
|
19
46
|
self.otp_regenerate_secret if !otp_column
|
20
47
|
self.otp_regenerate_counter if otp_counter_based && !otp_counter
|
48
|
+
otp_regenerate_backup_codes if backup_codes_enabled?
|
21
49
|
end
|
22
50
|
|
23
51
|
if respond_to?(:attributes_protected_by_default)
|
@@ -44,38 +72,21 @@ module ActiveModel
|
|
44
72
|
end
|
45
73
|
|
46
74
|
def authenticate_otp(code, options = {})
|
75
|
+
return false if code.nil? || code.empty?
|
76
|
+
return true if backup_codes_enabled? && authenticate_backup_code(code)
|
77
|
+
|
47
78
|
if otp_counter_based
|
48
|
-
|
49
|
-
result = hotp.verify(code, otp_counter)
|
50
|
-
if result && options[:auto_increment]
|
51
|
-
self.otp_counter += 1
|
52
|
-
save if respond_to?(:changed?) && !new_record?
|
53
|
-
end
|
54
|
-
result
|
79
|
+
otp_counter == authenticate_hotp(code, options)
|
55
80
|
else
|
56
|
-
|
57
|
-
if drift = options[:drift]
|
58
|
-
totp.verify(code, drift_behind: drift)
|
59
|
-
else
|
60
|
-
totp.verify(code)
|
61
|
-
end
|
81
|
+
authenticate_totp(code, options).present?
|
62
82
|
end
|
63
83
|
end
|
64
84
|
|
65
85
|
def otp_code(options = {})
|
66
86
|
if otp_counter_based
|
67
|
-
|
68
|
-
self.otp_counter += 1
|
69
|
-
save if respond_to?(:changed?) && !new_record?
|
70
|
-
end
|
71
|
-
ROTP::HOTP.new(otp_column, digits: otp_digits).at(self.otp_counter)
|
87
|
+
hotp_code(options)
|
72
88
|
else
|
73
|
-
|
74
|
-
time = options.fetch(:time, Time.now)
|
75
|
-
else
|
76
|
-
time = options
|
77
|
-
end
|
78
|
-
ROTP::TOTP.new(otp_column, digits: otp_digits).at(time)
|
89
|
+
totp_code(options)
|
79
90
|
end
|
80
91
|
end
|
81
92
|
|
@@ -84,9 +95,13 @@ module ActiveModel
|
|
84
95
|
account ||= ""
|
85
96
|
|
86
97
|
if otp_counter_based
|
87
|
-
ROTP::HOTP
|
98
|
+
ROTP::HOTP
|
99
|
+
.new(otp_column, options)
|
100
|
+
.provisioning_uri(account, self.otp_counter)
|
88
101
|
else
|
89
|
-
ROTP::TOTP
|
102
|
+
ROTP::TOTP
|
103
|
+
.new(otp_column, options)
|
104
|
+
.provisioning_uri(account)
|
90
105
|
end
|
91
106
|
end
|
92
107
|
|
@@ -120,6 +135,79 @@ module ActiveModel
|
|
120
135
|
options[:except] << self.class.otp_column_name
|
121
136
|
super(options)
|
122
137
|
end
|
138
|
+
|
139
|
+
def otp_regenerate_backup_codes
|
140
|
+
otp = ROTP::OTP.new(otp_column)
|
141
|
+
backup_codes = Array.new(self.class.otp_backup_codes_count) do
|
142
|
+
otp.generate_otp((SecureRandom.random_number(9e5) + 1e5).to_i)
|
143
|
+
end
|
144
|
+
|
145
|
+
public_send("#{self.class.otp_backup_codes_column_name}=", backup_codes)
|
146
|
+
end
|
147
|
+
|
148
|
+
def backup_codes_enabled?
|
149
|
+
self.class.attribute_method?(self.class.otp_backup_codes_column_name)
|
150
|
+
end
|
151
|
+
|
152
|
+
private
|
153
|
+
|
154
|
+
def authenticate_hotp(code, options = {})
|
155
|
+
hotp = ROTP::HOTP.new(otp_column, digits: otp_digits)
|
156
|
+
result = hotp.verify(code, otp_counter)
|
157
|
+
if result && options[:auto_increment]
|
158
|
+
self.otp_counter += 1
|
159
|
+
save if respond_to?(:changed?) && !new_record?
|
160
|
+
end
|
161
|
+
result
|
162
|
+
end
|
163
|
+
|
164
|
+
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)
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
def hotp_code(options = {})
|
178
|
+
if options[:auto_increment]
|
179
|
+
self.otp_counter += 1
|
180
|
+
save if respond_to?(:changed?) && !new_record?
|
181
|
+
end
|
182
|
+
ROTP::HOTP.new(otp_column, digits: otp_digits).at(otp_counter)
|
183
|
+
end
|
184
|
+
|
185
|
+
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)
|
196
|
+
end
|
197
|
+
|
198
|
+
def authenticate_backup_code(code)
|
199
|
+
backup_codes_column_name = self.class.otp_backup_codes_column_name
|
200
|
+
backup_codes = public_send(backup_codes_column_name)
|
201
|
+
return false unless backup_codes.present? && backup_codes.include?(code)
|
202
|
+
|
203
|
+
if self.class.otp_one_time_backup_codes
|
204
|
+
backup_codes.delete(code)
|
205
|
+
public_send("#{backup_codes_column_name}=", backup_codes)
|
206
|
+
save if respond_to?(:changed?) && !new_record?
|
207
|
+
end
|
208
|
+
|
209
|
+
true
|
210
|
+
end
|
123
211
|
end
|
124
212
|
end
|
125
213
|
end
|
data/test/models/user.rb
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
class User
|
2
2
|
extend ActiveModel::Callbacks
|
3
3
|
include ActiveModel::Serializers::JSON
|
4
|
-
include ActiveModel::Serializers::Xml
|
5
4
|
include ActiveModel::Validations
|
6
5
|
include ActiveModel::OneTimePassword
|
7
6
|
|
8
7
|
define_model_callbacks :create
|
9
|
-
attr_accessor :otp_secret_key, :email
|
8
|
+
attr_accessor :otp_secret_key, :otp_backup_codes, :email
|
9
|
+
|
10
|
+
has_one_time_password one_time_backup_codes: true
|
10
11
|
|
11
|
-
has_one_time_password
|
12
12
|
def attributes
|
13
13
|
{ "otp_secret_key" => otp_secret_key, "email" => email }
|
14
14
|
end
|
@@ -1,6 +1,8 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
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'
|
@@ -31,6 +33,23 @@ class OtpTest < MiniTest::Unit::TestCase
|
|
31
33
|
assert @visitor.authenticate_otp(code)
|
32
34
|
end
|
33
35
|
|
36
|
+
def test_authenticate_with_otp_passing_false_or_empty_codes
|
37
|
+
refute @user.authenticate_otp(nil)
|
38
|
+
refute @user.authenticate_otp('')
|
39
|
+
|
40
|
+
refute @visitor.authenticate_otp(nil)
|
41
|
+
refute @visitor.authenticate_otp('')
|
42
|
+
|
43
|
+
refute @member.authenticate_otp(nil)
|
44
|
+
refute @member.authenticate_otp('')
|
45
|
+
|
46
|
+
refute @ar_user.authenticate_otp(nil)
|
47
|
+
refute @ar_user.authenticate_otp('')
|
48
|
+
|
49
|
+
refute @opt_in.authenticate_otp(nil)
|
50
|
+
refute @opt_in.authenticate_otp('')
|
51
|
+
end
|
52
|
+
|
34
53
|
def test_counter_based_otp
|
35
54
|
code = @member.otp_code
|
36
55
|
assert @member.authenticate_otp(code)
|
@@ -58,15 +77,30 @@ class OtpTest < MiniTest::Unit::TestCase
|
|
58
77
|
|
59
78
|
@opt_in.otp_regenerate_secret
|
60
79
|
code = @opt_in.otp_code
|
61
|
-
|
80
|
+
assert_equal true, @opt_in.authenticate_otp(code)
|
62
81
|
end
|
63
82
|
|
64
83
|
def test_authenticate_with_otp_when_drift_is_allowed
|
65
84
|
code = @user.otp_code(Time.now - 30)
|
66
|
-
|
85
|
+
assert_equal true, @user.authenticate_otp(code, drift: 60)
|
67
86
|
|
68
87
|
code = @visitor.otp_code(Time.now - 30)
|
69
|
-
|
88
|
+
assert_equal true, @visitor.authenticate_otp(code, drift: 60)
|
89
|
+
end
|
90
|
+
|
91
|
+
def test_authenticate_with_backup_code
|
92
|
+
backup_code = @user.public_send(@user.otp_backup_codes_column_name).first
|
93
|
+
assert_equal true, @user.authenticate_otp(backup_code)
|
94
|
+
|
95
|
+
backup_code = @user.public_send(@user.otp_backup_codes_column_name).last
|
96
|
+
@user.otp_regenerate_backup_codes
|
97
|
+
assert_equal true, !@user.authenticate_otp(backup_code)
|
98
|
+
end
|
99
|
+
|
100
|
+
def test_authenticate_with_one_time_backup_code
|
101
|
+
backup_code = @user.public_send(@user.otp_backup_codes_column_name).first
|
102
|
+
assert_equal true, @user.authenticate_otp(backup_code)
|
103
|
+
assert_equal true, !@user.authenticate_otp(backup_code)
|
70
104
|
end
|
71
105
|
|
72
106
|
def test_otp_code
|
@@ -85,22 +119,51 @@ class OtpTest < MiniTest::Unit::TestCase
|
|
85
119
|
end
|
86
120
|
|
87
121
|
def test_provisioning_uri_with_provided_account
|
88
|
-
|
89
|
-
|
90
|
-
|
122
|
+
totp = %r{^otpauth://totp/roberto\?secret=\w{32}$}
|
123
|
+
hotp = %r{^otpauth://hotp/roberto\?secret=\w{32}&counter=1$}
|
124
|
+
|
125
|
+
assert_match totp, @user.provisioning_uri('roberto')
|
126
|
+
assert_match totp, @visitor.provisioning_uri('roberto')
|
127
|
+
assert_match hotp, @member.provisioning_uri('roberto')
|
91
128
|
end
|
92
129
|
|
93
130
|
def test_provisioning_uri_with_email_field
|
94
|
-
|
95
|
-
|
96
|
-
|
131
|
+
totp = %r{^otpauth://totp/roberto%40heapsource\.com\?secret=\w{32}$}
|
132
|
+
hotp = %r{^otpauth://hotp/\?secret=\w{32}&counter=1$}
|
133
|
+
|
134
|
+
assert_match totp, @user.provisioning_uri
|
135
|
+
assert_match totp, @visitor.provisioning_uri
|
136
|
+
assert_match hotp, @member.provisioning_uri
|
97
137
|
end
|
98
138
|
|
99
139
|
def test_provisioning_uri_with_options
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
140
|
+
account = %r{
|
141
|
+
^otpauth://totp/Example\:roberto\?secret=\w{32}&issuer=Example$
|
142
|
+
}x
|
143
|
+
|
144
|
+
email = %r{
|
145
|
+
^otpauth://totp/Example\:roberto%40heapsource\.com\?secret=\w{32}
|
146
|
+
&issuer=Example$
|
147
|
+
}x
|
148
|
+
|
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
|
+
)
|
156
|
+
|
157
|
+
assert_match email, @user.provisioning_uri(nil, issuer: 'Example')
|
158
|
+
assert_match email, @visitor.provisioning_uri(nil, issuer: 'Example')
|
159
|
+
end
|
160
|
+
|
161
|
+
def test_provisioning_uri_with_incremented_counter
|
162
|
+
2.times { @member.otp_code(auto_increment: true) }
|
163
|
+
|
164
|
+
hotp = %r{^otpauth://hotp/\?secret=\w{32}&counter=3$}
|
165
|
+
|
166
|
+
assert_match hotp, @member.provisioning_uri
|
104
167
|
end
|
105
168
|
|
106
169
|
def test_regenerate_otp
|
@@ -111,10 +174,29 @@ class OtpTest < MiniTest::Unit::TestCase
|
|
111
174
|
|
112
175
|
def test_hide_secret_key_in_serialize
|
113
176
|
refute_match(/otp_secret_key/, @user.to_json)
|
114
|
-
refute_match(/otp_secret_key/, @user.to_xml)
|
115
177
|
end
|
116
178
|
|
117
179
|
def test_otp_random_secret
|
118
|
-
assert_match
|
180
|
+
assert_match(/^.{32}$/, @user.class.otp_random_secret)
|
181
|
+
end
|
182
|
+
|
183
|
+
def test_otp_interval
|
184
|
+
@interval_user = IntervalUser.new
|
185
|
+
@interval_user.email = 'roberto@heapsource.com'
|
186
|
+
@interval_user.run_callbacks :create
|
187
|
+
otp_code = @interval_user.otp_code
|
188
|
+
2.times { assert_match(otp_code, @interval_user.otp_code) }
|
189
|
+
sleep 5
|
190
|
+
refute_match(otp_code, @interval_user.otp_code)
|
191
|
+
end
|
192
|
+
|
193
|
+
def test_otp_default_interval
|
194
|
+
@default_interval_user = DefaultIntervalUser.new
|
195
|
+
@default_interval_user.email = 'roberto@heapsource.com'
|
196
|
+
@default_interval_user.run_callbacks :create
|
197
|
+
otp_code = @default_interval_user.otp_code
|
198
|
+
2.times { assert_match(otp_code, @default_interval_user.otp_code) }
|
199
|
+
sleep 5
|
200
|
+
assert_match(otp_code, @default_interval_user.otp_code)
|
119
201
|
end
|
120
202
|
end
|
data/test/schema.rb
CHANGED
@@ -8,4 +8,20 @@ ActiveRecord::Schema.define do
|
|
8
8
|
t.string :otp_secret_key
|
9
9
|
t.timestamps
|
10
10
|
end
|
11
|
+
|
12
|
+
create_table :interval_users, force: true do |t|
|
13
|
+
t.string :key
|
14
|
+
t.string :email
|
15
|
+
t.integer :otp_counter
|
16
|
+
t.string :otp_secret_key
|
17
|
+
t.timestamps
|
18
|
+
end
|
19
|
+
|
20
|
+
create_table :default_interval_users, force: true do |t|
|
21
|
+
t.string :key
|
22
|
+
t.string :email
|
23
|
+
t.integer :otp_counter
|
24
|
+
t.string :otp_secret_key
|
25
|
+
t.timestamps
|
26
|
+
end
|
11
27
|
end
|
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.1
|
4
|
+
version: 2.3.1
|
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: 2021-10-22 00:00:00.000000000 Z
|
14
14
|
dependencies:
|
15
15
|
- !ruby/object:Gem::Dependency
|
16
16
|
name: activemodel
|
@@ -32,14 +32,14 @@ dependencies:
|
|
32
32
|
requirements:
|
33
33
|
- - "~>"
|
34
34
|
- !ruby/object:Gem::Version
|
35
|
-
version:
|
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:
|
42
|
+
version: 6.2.0
|
43
43
|
- !ruby/object:Gem::Dependency
|
44
44
|
name: activerecord
|
45
45
|
requirement: !ruby/object:Gem::Requirement
|
@@ -100,16 +100,16 @@ dependencies:
|
|
100
100
|
name: sqlite3
|
101
101
|
requirement: !ruby/object:Gem::Requirement
|
102
102
|
requirements:
|
103
|
-
- - "
|
103
|
+
- - ">="
|
104
104
|
- !ruby/object:Gem::Version
|
105
|
-
version:
|
105
|
+
version: '0'
|
106
106
|
type: :development
|
107
107
|
prerelease: false
|
108
108
|
version_requirements: !ruby/object:Gem::Requirement
|
109
109
|
requirements:
|
110
|
-
- - "
|
110
|
+
- - ">="
|
111
111
|
- !ruby/object:Gem::Version
|
112
|
-
version:
|
112
|
+
version: '0'
|
113
113
|
description: Adds methods to set and authenticate against one time passwords 2FA(Two
|
114
114
|
factor Authentication). Inspired in AM::SecurePassword"
|
115
115
|
email:
|
@@ -120,8 +120,8 @@ executables: []
|
|
120
120
|
extensions: []
|
121
121
|
extra_rdoc_files: []
|
122
122
|
files:
|
123
|
+
- ".github/workflows/active_model_otp.yml"
|
123
124
|
- ".gitignore"
|
124
|
-
- ".travis.yml"
|
125
125
|
- Appraisals
|
126
126
|
- CHANGELOG.md
|
127
127
|
- Gemfile
|
@@ -134,10 +134,13 @@ files:
|
|
134
134
|
- gemfiles/rails_5.1.gemfile
|
135
135
|
- gemfiles/rails_5.2.gemfile
|
136
136
|
- gemfiles/rails_6.0.gemfile
|
137
|
+
- gemfiles/rails_6.1.gemfile
|
137
138
|
- lib/active_model/one_time_password.rb
|
138
139
|
- lib/active_model/otp/version.rb
|
139
140
|
- lib/active_model_otp.rb
|
140
141
|
- test/models/activerecord_user.rb
|
142
|
+
- test/models/default_interval_user.rb
|
143
|
+
- test/models/interval_user.rb
|
141
144
|
- test/models/member.rb
|
142
145
|
- test/models/opt_in_two_factor.rb
|
143
146
|
- test/models/user.rb
|
@@ -170,6 +173,8 @@ specification_version: 4
|
|
170
173
|
summary: Adds methods to set and authenticate against one time passwords.
|
171
174
|
test_files:
|
172
175
|
- test/models/activerecord_user.rb
|
176
|
+
- test/models/default_interval_user.rb
|
177
|
+
- test/models/interval_user.rb
|
173
178
|
- test/models/member.rb
|
174
179
|
- test/models/opt_in_two_factor.rb
|
175
180
|
- test/models/user.rb
|
data/.travis.yml
DELETED
@@ -1,26 +0,0 @@
|
|
1
|
-
rvm:
|
2
|
-
- 2.3
|
3
|
-
- 2.4
|
4
|
-
- 2.5
|
5
|
-
- 2.6
|
6
|
-
- ruby-head
|
7
|
-
gemfile:
|
8
|
-
- gemfiles/rails_4.2.gemfile
|
9
|
-
- gemfiles/rails_5.0.gemfile
|
10
|
-
- gemfiles/rails_5.1.gemfile
|
11
|
-
- gemfiles/rails_5.2.gemfile
|
12
|
-
- gemfiles/rails_6.0.gemfile
|
13
|
-
matrix:
|
14
|
-
exclude:
|
15
|
-
- rvm: 2.3
|
16
|
-
gemfile: gemfiles/rails_6.0.gemfile
|
17
|
-
- rvm: 2.4
|
18
|
-
gemfile: gemfiles/rails_6.0.gemfile
|
19
|
-
fast_finish: true
|
20
|
-
allow_failures:
|
21
|
-
- rvm: ruby-head
|
22
|
-
# include:
|
23
|
-
# - rvm: jruby
|
24
|
-
# env: JRUBY_OPTS="--1.9 --server -Xcext.enabled=true"
|
25
|
-
notifications:
|
26
|
-
email: false
|