active_model_otp 1.1.0 → 2.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/.gitignore +1 -0
- data/.travis.yml +39 -6
- data/Appraisals +36 -0
- data/CHANGELOG.md +1 -10
- data/LICENSE.txt +1 -1
- data/README.md +129 -22
- data/active_model_otp.gemspec +12 -3
- data/gemfiles/rails_4.2.gemfile +8 -0
- data/gemfiles/rails_5.0.gemfile +9 -0
- data/gemfiles/rails_5.1.gemfile +9 -0
- data/gemfiles/rails_5.2.gemfile +9 -0
- data/gemfiles/rails_6.0.gemfile +10 -0
- data/gemfiles/rails_6.1.gemfile +10 -0
- data/lib/active_model/one_time_password.rb +142 -21
- data/lib/active_model/otp/version.rb +1 -1
- data/test/models/activerecord_user.rb +3 -0
- data/test/models/member.rb +10 -0
- data/test/models/opt_in_two_factor.rb +16 -0
- data/test/models/user.rb +7 -2
- data/test/one_time_password_test.rb +117 -21
- data/test/schema.rb +11 -0
- data/test/test_helper.rb +9 -0
- metadata +61 -19
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: bdc023d65f130fca41bde8f20a7094e587c7940240ae5b36778e54d2efcf76ac
|
4
|
+
data.tar.gz: 1dab4dd23328538a3a3c8d9a712b9a16d01673b3d997538169126966c653cdf6
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: f896afd30554da73cff730f4277aefe8805b4cbb3da0bf550039b14b8d4611374498b1cf4b0eae2fe80d7a35bdaafdc0ae1558ffbdea3f2f708d2334a81e1af2
|
7
|
+
data.tar.gz: 0d3b2a4fe8d0e26e9c06bd71e471bcabf4a048f1f1bd531691c0afbc5feda757147ca045ddedec34e587240b0b1337960a9248c0340e6f57ec5d7057cec776ea
|
data/.gitignore
CHANGED
data/.travis.yml
CHANGED
@@ -1,10 +1,43 @@
|
|
1
1
|
rvm:
|
2
|
-
-
|
3
|
-
- 2.
|
4
|
-
- 2.
|
2
|
+
- 2.3
|
3
|
+
- 2.4
|
4
|
+
- 2.5
|
5
|
+
- 2.6
|
6
|
+
- 2.7
|
7
|
+
- 3.0
|
8
|
+
- ruby-head
|
9
|
+
gemfile:
|
10
|
+
- gemfiles/rails_4.2.gemfile
|
11
|
+
- gemfiles/rails_5.0.gemfile
|
12
|
+
- gemfiles/rails_5.1.gemfile
|
13
|
+
- gemfiles/rails_5.2.gemfile
|
14
|
+
- gemfiles/rails_6.0.gemfile
|
15
|
+
- gemfiles/rails_6.1.gemfile
|
5
16
|
matrix:
|
6
|
-
|
7
|
-
- rvm:
|
8
|
-
|
17
|
+
exclude:
|
18
|
+
- rvm: 2.3
|
19
|
+
gemfile: gemfiles/rails_6.0.gemfile
|
20
|
+
- rvm: 2.3
|
21
|
+
gemfile: gemfiles/rails_6.1.gemfile
|
22
|
+
- rvm: 2.4
|
23
|
+
gemfile: gemfiles/rails_6.0.gemfile
|
24
|
+
- rvm: 2.4
|
25
|
+
gemfile: gemfiles/rails_6.1.gemfile
|
26
|
+
- rvm: 2.7
|
27
|
+
gemfile: gemfiles/rails_4.2.gemfile
|
28
|
+
- rvm: 3.0
|
29
|
+
gemfile: gemfiles/rails_4.2.gemfile
|
30
|
+
- rvm: 3.0
|
31
|
+
gemfile: gemfiles/rails_5.0.gemfile
|
32
|
+
- rvm: 3.0
|
33
|
+
gemfile: gemfiles/rails_5.1.gemfile
|
34
|
+
- rvm: 3.0
|
35
|
+
gemfile: gemfiles/rails_5.2.gemfile
|
36
|
+
fast_finish: true
|
37
|
+
allow_failures:
|
38
|
+
- rvm: ruby-head
|
39
|
+
# include:
|
40
|
+
# - rvm: jruby
|
41
|
+
# env: JRUBY_OPTS="--1.9 --server -Xcext.enabled=true"
|
9
42
|
notifications:
|
10
43
|
email: false
|
data/Appraisals
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
appraise "rails-4.2" do
|
2
|
+
gem "activemodel", "~> 4.2"
|
3
|
+
gem "sqlite3", "~> 1.3.6"
|
4
|
+
end
|
5
|
+
|
6
|
+
appraise "rails-5.0" do
|
7
|
+
gem "activemodel", "~> 5.0"
|
8
|
+
gem "activemodel-serializers-xml"
|
9
|
+
gem "sqlite3", "~> 1.3.6"
|
10
|
+
end
|
11
|
+
|
12
|
+
appraise "rails-5.1" do
|
13
|
+
gem "activemodel", "~> 5.1"
|
14
|
+
gem "activemodel-serializers-xml"
|
15
|
+
gem "sqlite3", "~> 1.3.6"
|
16
|
+
end
|
17
|
+
|
18
|
+
appraise "rails-5.2" do
|
19
|
+
gem "activemodel", "~> 5.2"
|
20
|
+
gem "activemodel-serializers-xml"
|
21
|
+
gem "sqlite3", "~> 1.3.6"
|
22
|
+
end
|
23
|
+
|
24
|
+
appraise "rails-6.0" do
|
25
|
+
gem "activerecord", "~> 6.0"
|
26
|
+
gem "activemodel", "~> 6.0"
|
27
|
+
gem "activemodel-serializers-xml"
|
28
|
+
gem "sqlite3", "~> 1.4"
|
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/CHANGELOG.md
CHANGED
@@ -1,10 +1 @@
|
|
1
|
-
|
2
|
-
#v1.0.0
|
3
|
-
- Avoid overriding predefined otp_column value when initializing resource (Ilan Stern) https://github.com/heapsource/active_model_otp/pull/10
|
4
|
-
- Pad OTP codes with less than 6 digits (Johan Brissmyr) https://github.com/heapsource/active_model_otp/pull/7
|
5
|
-
- Get rid of deprecation warnings in Rails 4.1 (Nick DeMonner)
|
6
|
-
|
7
|
-
#v0.1.0
|
8
|
-
- OTP codes can be in 5 or 6 digits (André Luis Leal Cardoso Junior)
|
9
|
-
- Require 'cgi', rotp needs it for encoding parameters (André Luis Leal Cardoso Junior)
|
10
|
-
- Change column name for otp secret key (robertomiranda)
|
1
|
+
CHANGELOG it's been deprecated in favor of https://github.com/heapsource/active_model_otp/releases
|
data/LICENSE.txt
CHANGED
data/README.md
CHANGED
@@ -1,8 +1,16 @@
|
|
1
1
|
[![Build Status](https://travis-ci.org/heapsource/active_model_otp.png)](https://travis-ci.org/heapsource/active_model_otp)
|
2
|
+
[![Gem Version](https://badge.fury.io/rb/active_model_otp.svg)](http://badge.fury.io/rb/active_model_otp)
|
3
|
+
[![Reviewed by Hound](https://img.shields.io/badge/Reviewed_by-Hound-8E64B0.svg)](https://houndci.com)
|
4
|
+
|
2
5
|
|
3
6
|
# ActiveModel::Otp
|
4
7
|
|
5
|
-
**ActiveModel::Otp** makes adding **Two Factor Authentication**(TFA) to a model simple
|
8
|
+
**ActiveModel::Otp** makes adding **Two Factor Authentication** (TFA) to a model simple. Let's see what's required to get AMo::Otp working in our Application, using Rails 5.0 (AMo::Otp is also compatible with Rails 4.x versions). We're going to use a User model and try to add options provided by **ActiveModel::Otp**. Inspired by AM::SecurePassword
|
9
|
+
|
10
|
+
## Dependencies
|
11
|
+
|
12
|
+
* [ROTP](https://github.com/mdp/rotp) 6.2.0 or higher
|
13
|
+
* Ruby 2.3 or greater
|
6
14
|
|
7
15
|
## Installation
|
8
16
|
|
@@ -14,7 +22,7 @@ And then execute:
|
|
14
22
|
|
15
23
|
$ bundle
|
16
24
|
|
17
|
-
Or install it yourself as:
|
25
|
+
Or install it yourself as follows:
|
18
26
|
|
19
27
|
$ gem install active_model_otp
|
20
28
|
|
@@ -29,33 +37,32 @@ rails g migration AddOtpSecretKeyToUsers otp_secret_key:string
|
|
29
37
|
create db/migrate/20130707010931_add_otp_secret_key_to_users.rb
|
30
38
|
```
|
31
39
|
|
32
|
-
We’ll then need to run rake db:migrate to update the users table in the database. The next step is to update the model code. We need to use has_one_time_password to
|
40
|
+
We’ll then need to run rake db:migrate to update the users table in the database. The next step is to update the model code. We need to use has_one_time_password to make it use TFA.
|
33
41
|
|
34
42
|
```ruby
|
35
|
-
class User <
|
43
|
+
class User < ApplicationRecord
|
36
44
|
has_one_time_password
|
37
45
|
end
|
38
46
|
```
|
39
47
|
|
40
48
|
Note: If you're adding this to an existing user model you'll need to generate *otp_secret_key* with a migration like:
|
41
49
|
```ruby
|
42
|
-
User.
|
50
|
+
User.find_each { |user| user.update_attribute(:otp_secret_key, User.otp_random_secret) }
|
43
51
|
```
|
44
52
|
|
45
|
-
|
53
|
+
To use a custom column to store the secret key field you can use the column_name option. It is also possible to generate codes with a specified length.
|
46
54
|
|
47
55
|
```ruby
|
48
|
-
class User <
|
49
|
-
has_one_time_password column_name: :my_otp_secret_column
|
56
|
+
class User < ApplicationRecord
|
57
|
+
has_one_time_password column_name: :my_otp_secret_column, length: 4
|
50
58
|
end
|
51
59
|
```
|
52
60
|
|
61
|
+
## Usage
|
53
62
|
|
54
|
-
|
63
|
+
The has_one_time_password statement provides to the model some useful methods in order to implement our TFA system. AMo:Otp generates one time passwords according to [TOTP RFC 6238](https://tools.ietf.org/html/rfc6238) and the [HOTP RFC 4226](https://www.ietf.org/rfc/rfc4226). This is compatible with Google Authenticator apps available for Android and iPhone, and now in use on GMail.
|
55
64
|
|
56
|
-
The
|
57
|
-
|
58
|
-
The otp_secret_key is saved automatically when a object is created,
|
65
|
+
The otp_secret_key is saved automatically when an object is created,
|
59
66
|
|
60
67
|
```ruby
|
61
68
|
user = User.create(email: "hello@heapsource.com")
|
@@ -63,9 +70,9 @@ user.otp_secret_key
|
|
63
70
|
=> "jt3gdd2qm6su5iqh"
|
64
71
|
```
|
65
72
|
|
66
|
-
**Note:** You can fork the applications for [iPhone](https://github.com/heapsource/google-authenticator) & [Android](https://github.com/heapsource/google-authenticator.android) and customize
|
73
|
+
**Note:** You can fork the applications for [iPhone](https://github.com/heapsource/google-authenticator) & [Android](https://github.com/heapsource/google-authenticator.android) and customize them
|
67
74
|
|
68
|
-
### Getting current code (
|
75
|
+
### Getting current code (e.g. to send via SMS)
|
69
76
|
```ruby
|
70
77
|
user.otp_code # => '186522'
|
71
78
|
sleep 30
|
@@ -73,9 +80,6 @@ user.otp_code # => '850738'
|
|
73
80
|
|
74
81
|
# Override current time
|
75
82
|
user.otp_code(time: Time.now + 3600) # => '317438'
|
76
|
-
|
77
|
-
# Don't zero-pad to six digits
|
78
|
-
user.otp_code(padding: false) # => '438'
|
79
83
|
```
|
80
84
|
|
81
85
|
### Authenticating using a code
|
@@ -94,19 +98,120 @@ sleep 30 # lets wait again
|
|
94
98
|
user.authenticate_otp('186522', drift: 60) # => true
|
95
99
|
```
|
96
100
|
|
101
|
+
## Counter based OTP
|
102
|
+
|
103
|
+
An additonal counter field is required in our ``User`` Model
|
104
|
+
|
105
|
+
```ruby
|
106
|
+
rails g migration AddCounterForOtpToUsers otp_counter:integer
|
107
|
+
=>
|
108
|
+
invoke active_record
|
109
|
+
create db/migrate/20130707010931_add_counter_for_otp_to_users.rb
|
110
|
+
```
|
111
|
+
|
112
|
+
Set default value for otp_counter to 0.
|
113
|
+
```ruby
|
114
|
+
change_column :users, :otp_counter, :integer, default: 0
|
115
|
+
```
|
116
|
+
|
117
|
+
In addition set the counter flag option to true
|
118
|
+
|
119
|
+
```ruby
|
120
|
+
class User < ApplicationRecord
|
121
|
+
has_one_time_password counter_based: true
|
122
|
+
end
|
123
|
+
```
|
124
|
+
|
125
|
+
And for a custom counter column
|
126
|
+
|
127
|
+
```ruby
|
128
|
+
class User < ApplicationRecord
|
129
|
+
has_one_time_password counter_based: true, counter_column_name: :my_otp_secret_counter_column
|
130
|
+
end
|
131
|
+
```
|
132
|
+
|
133
|
+
Authentication is done the same. You can manually adjust the counter for your usage or set auto_increment on success to true.
|
134
|
+
|
135
|
+
```ruby
|
136
|
+
user.authenticate_otp('186522') # => true
|
137
|
+
user.authenticate_otp('186522', auto_increment: true) # => true
|
138
|
+
user.authenticate_otp('186522') # => false
|
139
|
+
user.otp_counter -= 1
|
140
|
+
user.authenticate_otp('186522') # => true
|
141
|
+
```
|
142
|
+
|
143
|
+
When retrieving an ```otp_code``` you can also pass the ```auto_increment``` option.
|
144
|
+
|
145
|
+
```ruby
|
146
|
+
user.otp_code # => '186522'
|
147
|
+
user.otp_code # => '186522'
|
148
|
+
user.otp_code(auto_increment: true) # => '768273'
|
149
|
+
user.otp_code(auto_increment: true) # => '002811'
|
150
|
+
user.otp_code # => '002811'
|
151
|
+
```
|
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
|
+
|
97
198
|
## Google Authenticator Compatible
|
98
199
|
|
99
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.
|
100
201
|
|
101
202
|
```ruby
|
102
|
-
# Use
|
203
|
+
# Use your user's email address to generate the provisioning_url
|
103
204
|
user.provisioning_uri # => 'otpauth://totp/hello@heapsource.com?secret=2z6hxkdwi3uvrnpn'
|
104
205
|
|
105
|
-
# Use a custom
|
206
|
+
# Use a custom field to generate the provisioning_url
|
106
207
|
user.provisioning_uri("hello") # => 'otpauth://totp/hello?secret=2z6hxkdwi3uvrnpn'
|
208
|
+
|
209
|
+
# You can customize the generated url, by passing a hash of Options
|
210
|
+
# `:issuer` lets you set the Issuer name in Google Authenticator, so it doesn't show as a blank entry.
|
211
|
+
user.provisioning_uri(nil, issuer: 'MYAPP') #=> 'otpauth://totp/hello@heapsource.com?secret=2z6hxkdwi3uvrnpn&issuer=MYAPP'
|
107
212
|
```
|
108
213
|
|
109
|
-
This can then be rendered as a QR Code which can
|
214
|
+
This can then be rendered as a QR Code which can be scanned and added to the users list of OTP credentials.
|
110
215
|
|
111
216
|
### Working example
|
112
217
|
|
@@ -118,6 +223,7 @@ Now run the following and compare the output
|
|
118
223
|
|
119
224
|
```ruby
|
120
225
|
require "active_model_otp"
|
226
|
+
|
121
227
|
class User
|
122
228
|
extend ActiveModel::Callbacks
|
123
229
|
include ActiveModel::Validations
|
@@ -128,6 +234,7 @@ class User
|
|
128
234
|
|
129
235
|
has_one_time_password
|
130
236
|
end
|
237
|
+
|
131
238
|
user = User.new
|
132
239
|
user.email = 'roberto@heapsource.com'
|
133
240
|
user.otp_secret_key = "2z6hxkdwi3uvrnpn"
|
@@ -137,10 +244,10 @@ puts "Current code #{user.otp_code}"
|
|
137
244
|
**Note:** otp_secret_key must be generated using RFC 3548 base32 key strings (for compatilibity with google authenticator)
|
138
245
|
|
139
246
|
### Useful Examples
|
140
|
-
|
247
|
+
- [Drifting Ruby Tutorial](https://www.driftingruby.com/episodes/two-factor-authentication)
|
141
248
|
- [Generate QR code with rqrcode gem](https://github.com/heapsource/active_model_otp/wiki/Generate-QR-code-with-rqrcode-gem)
|
142
249
|
- Generating QR Code with Google Charts API
|
143
|
-
- [
|
250
|
+
- [Sending code via SMS with Twilio](https://github.com/heapsource/active_model_otp/wiki/Send-code-via-Twilio-SMS)
|
144
251
|
- [Using with Mongoid](https://github.com/heapsource/active_model_otp/wiki/Using-with-Mongoid)
|
145
252
|
|
146
253
|
## Contributing
|
data/active_model_otp.gemspec
CHANGED
@@ -8,7 +8,7 @@ Gem::Specification.new do |spec|
|
|
8
8
|
spec.version = ActiveModel::Otp::VERSION
|
9
9
|
spec.authors = ["Guillermo Iguaran", "Roberto Miranda", "Heapsource"]
|
10
10
|
spec.email = ["guilleiguaran@gmail.com", "rjmaltamar@gmail.com", "hello@firebase.co"]
|
11
|
-
spec.description = %q{Adds methods to set and authenticate against one time passwords. Inspired in AM::SecurePassword"}
|
11
|
+
spec.description = %q{Adds methods to set and authenticate against one time passwords 2FA(Two factor Authentication). Inspired in AM::SecurePassword"}
|
12
12
|
spec.summary = "Adds methods to set and authenticate against one time passwords."
|
13
13
|
spec.homepage = ""
|
14
14
|
spec.license = "MIT"
|
@@ -18,10 +18,19 @@ Gem::Specification.new do |spec|
|
|
18
18
|
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
19
|
spec.require_paths = ["lib"]
|
20
20
|
|
21
|
+
spec.required_ruby_version = ">= 2.3"
|
22
|
+
|
21
23
|
spec.add_dependency "activemodel"
|
22
|
-
spec.add_dependency "rotp"
|
24
|
+
spec.add_dependency "rotp", "~> 6.2.0"
|
23
25
|
|
24
|
-
spec.add_development_dependency "
|
26
|
+
spec.add_development_dependency "activerecord"
|
25
27
|
spec.add_development_dependency "rake"
|
26
28
|
spec.add_development_dependency "minitest", "~> 5.4.2"
|
29
|
+
spec.add_development_dependency "appraisal"
|
30
|
+
|
31
|
+
if RUBY_PLATFORM == "java"
|
32
|
+
spec.add_development_dependency "activerecord-jdbcsqlite3-adapter"
|
33
|
+
else
|
34
|
+
spec.add_development_dependency "sqlite3"
|
35
|
+
end
|
27
36
|
end
|
@@ -3,63 +3,184 @@ module ActiveModel
|
|
3
3
|
extend ActiveSupport::Concern
|
4
4
|
|
5
5
|
module ClassMethods
|
6
|
-
|
7
6
|
def has_one_time_password(options = {})
|
8
|
-
|
9
|
-
|
10
|
-
class_attribute :otp_digits
|
7
|
+
cattr_accessor :otp_column_name, :otp_counter_column_name,
|
8
|
+
:otp_backup_codes_column_name
|
9
|
+
class_attribute :otp_digits, :otp_counter_based,
|
10
|
+
:otp_backup_codes_count, :otp_one_time_backup_codes
|
11
11
|
|
12
12
|
self.otp_column_name = (options[:column_name] || "otp_secret_key").to_s
|
13
13
|
self.otp_digits = options[:length] || 6
|
14
14
|
|
15
|
+
self.otp_counter_based = (options[:counter_based] || false)
|
16
|
+
self.otp_counter_column_name = (options[:counter_column_name] || "otp_counter").to_s
|
17
|
+
|
18
|
+
self.otp_backup_codes_column_name = (
|
19
|
+
options[:backup_codes_column_name] || 'otp_backup_codes'
|
20
|
+
).to_s
|
21
|
+
self.otp_backup_codes_count = options[:backup_codes_count] || 12
|
22
|
+
self.otp_one_time_backup_codes = (
|
23
|
+
options[:one_time_backup_codes] || false
|
24
|
+
)
|
25
|
+
|
15
26
|
include InstanceMethodsOnActivation
|
16
27
|
|
17
|
-
before_create
|
28
|
+
before_create(**options.slice(:if, :unless)) do
|
29
|
+
self.otp_regenerate_secret if !otp_column
|
30
|
+
self.otp_regenerate_counter if otp_counter_based && !otp_counter
|
31
|
+
otp_regenerate_backup_codes if backup_codes_enabled?
|
32
|
+
end
|
18
33
|
|
19
34
|
if respond_to?(:attributes_protected_by_default)
|
20
35
|
def self.attributes_protected_by_default #:nodoc:
|
21
|
-
super + [
|
36
|
+
super + [otp_column_name, otp_counter_column_name]
|
22
37
|
end
|
23
38
|
end
|
24
39
|
end
|
40
|
+
|
41
|
+
# Defaults to 160 bit long secret
|
42
|
+
# (meaning a 32 character long base32 secret)
|
43
|
+
def otp_random_secret(length = 20)
|
44
|
+
ROTP::Base32.random(length)
|
45
|
+
end
|
25
46
|
end
|
26
47
|
|
27
48
|
module InstanceMethodsOnActivation
|
28
49
|
def otp_regenerate_secret
|
29
|
-
self.otp_column =
|
50
|
+
self.otp_column = self.class.otp_random_secret
|
51
|
+
end
|
52
|
+
|
53
|
+
def otp_regenerate_counter
|
54
|
+
self.otp_counter = 1
|
30
55
|
end
|
31
56
|
|
32
57
|
def authenticate_otp(code, options = {})
|
33
|
-
|
34
|
-
|
35
|
-
|
58
|
+
return true if backup_codes_enabled? && authenticate_backup_code(code)
|
59
|
+
|
60
|
+
if otp_counter_based
|
61
|
+
otp_counter == authenticate_hotp(code, options)
|
36
62
|
else
|
37
|
-
|
63
|
+
authenticate_totp(code, options).present?
|
38
64
|
end
|
39
65
|
end
|
40
66
|
|
41
67
|
def otp_code(options = {})
|
42
|
-
if
|
43
|
-
|
44
|
-
padding = options.fetch(:padding, true)
|
68
|
+
if otp_counter_based
|
69
|
+
hotp_code(options)
|
45
70
|
else
|
46
|
-
|
47
|
-
padding = true
|
71
|
+
totp_code(options)
|
48
72
|
end
|
49
|
-
ROTP::TOTP.new(self.otp_column, {digits: self.otp_digits}).at(time, padding)
|
50
73
|
end
|
51
74
|
|
52
|
-
def provisioning_uri(account = nil)
|
75
|
+
def provisioning_uri(account = nil, options = {})
|
53
76
|
account ||= self.email if self.respond_to?(:email)
|
54
|
-
|
77
|
+
account ||= ""
|
78
|
+
|
79
|
+
if otp_counter_based
|
80
|
+
ROTP::HOTP
|
81
|
+
.new(otp_column, options)
|
82
|
+
.provisioning_uri(account, self.otp_counter)
|
83
|
+
else
|
84
|
+
ROTP::TOTP
|
85
|
+
.new(otp_column, options)
|
86
|
+
.provisioning_uri(account)
|
87
|
+
end
|
55
88
|
end
|
56
89
|
|
57
90
|
def otp_column
|
58
|
-
self.
|
91
|
+
self.public_send(self.class.otp_column_name)
|
59
92
|
end
|
60
93
|
|
61
94
|
def otp_column=(attr)
|
62
|
-
self.
|
95
|
+
self.public_send("#{self.class.otp_column_name}=", attr)
|
96
|
+
end
|
97
|
+
|
98
|
+
def otp_counter
|
99
|
+
if self.class.otp_counter_column_name != "otp_counter"
|
100
|
+
self.public_send(self.class.otp_counter_column_name)
|
101
|
+
else
|
102
|
+
super
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def otp_counter=(attr)
|
107
|
+
if self.class.otp_counter_column_name != "otp_counter"
|
108
|
+
self.public_send("#{self.class.otp_counter_column_name}=", attr)
|
109
|
+
else
|
110
|
+
super
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
def serializable_hash(options = nil)
|
115
|
+
options ||= {}
|
116
|
+
options[:except] = Array(options[:except])
|
117
|
+
options[:except] << self.class.otp_column_name
|
118
|
+
super(options)
|
119
|
+
end
|
120
|
+
|
121
|
+
def otp_regenerate_backup_codes
|
122
|
+
otp = ROTP::OTP.new(otp_column)
|
123
|
+
backup_codes = Array.new(self.class.otp_backup_codes_count) do
|
124
|
+
otp.generate_otp((SecureRandom.random_number(9e5) + 1e5).to_i)
|
125
|
+
end
|
126
|
+
|
127
|
+
public_send("#{self.class.otp_backup_codes_column_name}=", backup_codes)
|
128
|
+
end
|
129
|
+
|
130
|
+
def backup_codes_enabled?
|
131
|
+
self.class.attribute_method?(self.class.otp_backup_codes_column_name)
|
132
|
+
end
|
133
|
+
|
134
|
+
private
|
135
|
+
|
136
|
+
def authenticate_hotp(code, options = {})
|
137
|
+
hotp = ROTP::HOTP.new(otp_column, digits: otp_digits)
|
138
|
+
result = hotp.verify(code, otp_counter)
|
139
|
+
if result && options[:auto_increment]
|
140
|
+
self.otp_counter += 1
|
141
|
+
save if respond_to?(:changed?) && !new_record?
|
142
|
+
end
|
143
|
+
result
|
144
|
+
end
|
145
|
+
|
146
|
+
def authenticate_totp(code, options = {})
|
147
|
+
totp = ROTP::TOTP.new(otp_column, digits: otp_digits)
|
148
|
+
if (drift = options[:drift])
|
149
|
+
totp.verify(code, drift_behind: drift)
|
150
|
+
else
|
151
|
+
totp.verify(code)
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
def hotp_code(options = {})
|
156
|
+
if options[:auto_increment]
|
157
|
+
self.otp_counter += 1
|
158
|
+
save if respond_to?(:changed?) && !new_record?
|
159
|
+
end
|
160
|
+
ROTP::HOTP.new(otp_column, digits: otp_digits).at(otp_counter)
|
161
|
+
end
|
162
|
+
|
163
|
+
def totp_code(options = {})
|
164
|
+
time = if options.is_a?(Hash)
|
165
|
+
options.fetch(:time, Time.now)
|
166
|
+
else
|
167
|
+
options
|
168
|
+
end
|
169
|
+
ROTP::TOTP.new(otp_column, digits: otp_digits).at(time)
|
170
|
+
end
|
171
|
+
|
172
|
+
def authenticate_backup_code(code)
|
173
|
+
backup_codes_column_name = self.class.otp_backup_codes_column_name
|
174
|
+
backup_codes = public_send(backup_codes_column_name)
|
175
|
+
return false unless backup_codes.present? && backup_codes.include?(code)
|
176
|
+
|
177
|
+
if self.class.otp_one_time_backup_codes
|
178
|
+
backup_codes.delete(code)
|
179
|
+
public_send("#{backup_codes_column_name}=", backup_codes)
|
180
|
+
save if respond_to?(:changed?) && !new_record?
|
181
|
+
end
|
182
|
+
|
183
|
+
true
|
63
184
|
end
|
64
185
|
end
|
65
186
|
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class OptInTwoFactor
|
4
|
+
extend ActiveModel::Callbacks
|
5
|
+
include ActiveModel::Validations
|
6
|
+
include ActiveModel::OneTimePassword
|
7
|
+
|
8
|
+
define_model_callbacks :create
|
9
|
+
attr_accessor :otp_secret_key, :email
|
10
|
+
|
11
|
+
has_one_time_password unless: :otp_opt_in?
|
12
|
+
|
13
|
+
def otp_opt_in?
|
14
|
+
true
|
15
|
+
end
|
16
|
+
end
|
data/test/models/user.rb
CHANGED
@@ -1,10 +1,15 @@
|
|
1
1
|
class User
|
2
2
|
extend ActiveModel::Callbacks
|
3
|
+
include ActiveModel::Serializers::JSON
|
3
4
|
include ActiveModel::Validations
|
4
5
|
include ActiveModel::OneTimePassword
|
5
6
|
|
6
7
|
define_model_callbacks :create
|
7
|
-
attr_accessor :otp_secret_key, :email
|
8
|
+
attr_accessor :otp_secret_key, :otp_backup_codes, :email
|
8
9
|
|
9
|
-
has_one_time_password
|
10
|
+
has_one_time_password one_time_backup_codes: true
|
11
|
+
|
12
|
+
def attributes
|
13
|
+
{ "otp_secret_key" => otp_secret_key, "email" => email }
|
14
|
+
end
|
10
15
|
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'
|
@@ -9,23 +11,79 @@ class OtpTest < MiniTest::Unit::TestCase
|
|
9
11
|
@visitor = Visitor.new
|
10
12
|
@visitor.email = 'roberto@heapsource.com'
|
11
13
|
@visitor.run_callbacks :create
|
14
|
+
|
15
|
+
@member = Member.new
|
16
|
+
@member.email = nil
|
17
|
+
@member.run_callbacks :create
|
18
|
+
|
19
|
+
@ar_user = ActiverecordUser.new
|
20
|
+
@ar_user.email = 'roberto@heapsource.com'
|
21
|
+
@ar_user.run_callbacks :create
|
22
|
+
|
23
|
+
@opt_in = OptInTwoFactor.new
|
24
|
+
@opt_in.email = 'roberto@heapsource.com'
|
25
|
+
@opt_in.run_callbacks :create
|
12
26
|
end
|
13
27
|
|
14
28
|
def test_authenticate_with_otp
|
15
29
|
code = @user.otp_code
|
16
|
-
|
17
30
|
assert @user.authenticate_otp(code)
|
18
31
|
|
19
32
|
code = @visitor.otp_code
|
20
33
|
assert @visitor.authenticate_otp(code)
|
21
34
|
end
|
22
35
|
|
36
|
+
def test_counter_based_otp
|
37
|
+
code = @member.otp_code
|
38
|
+
assert @member.authenticate_otp(code)
|
39
|
+
assert @member.authenticate_otp(code, auto_increment: true)
|
40
|
+
assert !@member.authenticate_otp(code)
|
41
|
+
@member.otp_counter -= 1
|
42
|
+
assert @member.authenticate_otp(code)
|
43
|
+
assert code == @member.otp_code
|
44
|
+
assert code != @member.otp_code(auto_increment: true)
|
45
|
+
end
|
46
|
+
|
47
|
+
def test_counter_based_otp_active_record
|
48
|
+
code = @ar_user.otp_code
|
49
|
+
assert @ar_user.authenticate_otp(code)
|
50
|
+
assert @ar_user.authenticate_otp(code, auto_increment: true)
|
51
|
+
assert !@ar_user.authenticate_otp(code)
|
52
|
+
@ar_user.otp_counter -= 1
|
53
|
+
assert @ar_user.authenticate_otp(code)
|
54
|
+
assert code == @ar_user.otp_code
|
55
|
+
assert code != @ar_user.otp_code(auto_increment: true)
|
56
|
+
end
|
57
|
+
|
58
|
+
def test_opt_in_two_factor
|
59
|
+
assert @opt_in.otp_column.nil?
|
60
|
+
|
61
|
+
@opt_in.otp_regenerate_secret
|
62
|
+
code = @opt_in.otp_code
|
63
|
+
assert_equal true, @opt_in.authenticate_otp(code)
|
64
|
+
end
|
65
|
+
|
23
66
|
def test_authenticate_with_otp_when_drift_is_allowed
|
24
67
|
code = @user.otp_code(Time.now - 30)
|
25
|
-
|
68
|
+
assert_equal true, @user.authenticate_otp(code, drift: 60)
|
26
69
|
|
27
70
|
code = @visitor.otp_code(Time.now - 30)
|
28
|
-
|
71
|
+
assert_equal true, @visitor.authenticate_otp(code, drift: 60)
|
72
|
+
end
|
73
|
+
|
74
|
+
def test_authenticate_with_backup_code
|
75
|
+
backup_code = @user.public_send(@user.otp_backup_codes_column_name).first
|
76
|
+
assert_equal true, @user.authenticate_otp(backup_code)
|
77
|
+
|
78
|
+
backup_code = @user.public_send(@user.otp_backup_codes_column_name).last
|
79
|
+
@user.otp_regenerate_backup_codes
|
80
|
+
assert_equal true, !@user.authenticate_otp(backup_code)
|
81
|
+
end
|
82
|
+
|
83
|
+
def test_authenticate_with_one_time_backup_code
|
84
|
+
backup_code = @user.public_send(@user.otp_backup_codes_column_name).first
|
85
|
+
assert_equal true, @user.authenticate_otp(backup_code)
|
86
|
+
assert_equal true, !@user.authenticate_otp(backup_code)
|
29
87
|
end
|
30
88
|
|
31
89
|
def test_otp_code
|
@@ -34,31 +92,61 @@ class OtpTest < MiniTest::Unit::TestCase
|
|
34
92
|
end
|
35
93
|
|
36
94
|
def test_otp_code_with_specific_length
|
37
|
-
assert_match(/^\d{4}$/, @visitor.otp_code(
|
38
|
-
assert_operator(@visitor.otp_code(
|
95
|
+
assert_match(/^\d{4}$/, @visitor.otp_code(2160).to_s)
|
96
|
+
assert_operator(@visitor.otp_code(2160).to_s.length, :<=, 4)
|
39
97
|
end
|
40
98
|
|
41
99
|
def test_otp_code_without_specific_length
|
42
|
-
|
43
|
-
|
44
|
-
end
|
45
|
-
|
46
|
-
def test_otp_code_padding
|
47
|
-
@user.otp_column = 'kw5jhligwqaiw7jc'
|
48
|
-
assert_match(/^\d{6}$/, @user.otp_code(time: 2160, padding: true).to_s)
|
49
|
-
# Modified this spec as it is not guranteed that without padding we will always
|
50
|
-
# get a 3 digit number
|
51
|
-
assert_operator(@user.otp_code(time: 2160, padding: false).to_s.length, :<= , 6)
|
100
|
+
assert_match(/^\d{6}$/, @user.otp_code(2160).to_s)
|
101
|
+
assert_operator(@user.otp_code(2160).to_s.length, :<=, 6)
|
52
102
|
end
|
53
103
|
|
54
104
|
def test_provisioning_uri_with_provided_account
|
55
|
-
|
56
|
-
|
105
|
+
totp = %r{^otpauth://totp/roberto\?secret=\w{32}$}
|
106
|
+
hotp = %r{^otpauth://hotp/roberto\?secret=\w{32}&counter=1$}
|
107
|
+
|
108
|
+
assert_match totp, @user.provisioning_uri('roberto')
|
109
|
+
assert_match totp, @visitor.provisioning_uri('roberto')
|
110
|
+
assert_match hotp, @member.provisioning_uri('roberto')
|
57
111
|
end
|
58
112
|
|
59
113
|
def test_provisioning_uri_with_email_field
|
60
|
-
|
61
|
-
|
114
|
+
totp = %r{^otpauth://totp/roberto%40heapsource\.com\?secret=\w{32}$}
|
115
|
+
hotp = %r{^otpauth://hotp/\?secret=\w{32}&counter=1$}
|
116
|
+
|
117
|
+
assert_match totp, @user.provisioning_uri
|
118
|
+
assert_match totp, @visitor.provisioning_uri
|
119
|
+
assert_match hotp, @member.provisioning_uri
|
120
|
+
end
|
121
|
+
|
122
|
+
def test_provisioning_uri_with_options
|
123
|
+
account = %r{
|
124
|
+
^otpauth://totp/Example\:roberto\?secret=\w{32}&issuer=Example$
|
125
|
+
}x
|
126
|
+
|
127
|
+
email = %r{
|
128
|
+
^otpauth://totp/Example\:roberto%40heapsource\.com\?secret=\w{32}
|
129
|
+
&issuer=Example$
|
130
|
+
}x
|
131
|
+
|
132
|
+
assert_match(
|
133
|
+
account, @user.provisioning_uri('roberto', issuer: 'Example')
|
134
|
+
)
|
135
|
+
|
136
|
+
assert_match(
|
137
|
+
account, @visitor.provisioning_uri('roberto', issuer: 'Example')
|
138
|
+
)
|
139
|
+
|
140
|
+
assert_match email, @user.provisioning_uri(nil, issuer: 'Example')
|
141
|
+
assert_match email, @visitor.provisioning_uri(nil, issuer: 'Example')
|
142
|
+
end
|
143
|
+
|
144
|
+
def test_provisioning_uri_with_incremented_counter
|
145
|
+
2.times { @member.otp_code(auto_increment: true) }
|
146
|
+
|
147
|
+
hotp = %r{^otpauth://hotp/\?secret=\w{32}&counter=3$}
|
148
|
+
|
149
|
+
assert_match hotp, @member.provisioning_uri
|
62
150
|
end
|
63
151
|
|
64
152
|
def test_regenerate_otp
|
@@ -66,4 +154,12 @@ class OtpTest < MiniTest::Unit::TestCase
|
|
66
154
|
@user.otp_regenerate_secret
|
67
155
|
assert secret != @user.otp_column
|
68
156
|
end
|
157
|
+
|
158
|
+
def test_hide_secret_key_in_serialize
|
159
|
+
refute_match(/otp_secret_key/, @user.to_json)
|
160
|
+
end
|
161
|
+
|
162
|
+
def test_otp_random_secret
|
163
|
+
assert_match(/^.{32}$/, @user.class.otp_random_secret)
|
164
|
+
end
|
69
165
|
end
|
data/test/schema.rb
ADDED
data/test/test_helper.rb
CHANGED
@@ -8,5 +8,14 @@ require "rubygems"
|
|
8
8
|
require "active_model_otp"
|
9
9
|
require "minitest/autorun"
|
10
10
|
require "minitest/unit"
|
11
|
+
require "active_record"
|
12
|
+
|
13
|
+
begin
|
14
|
+
require "activemodel-serializers-xml"
|
15
|
+
rescue LoadError
|
16
|
+
end
|
17
|
+
|
18
|
+
ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:"
|
19
|
+
load "#{ File.dirname(__FILE__) }/schema.rb"
|
11
20
|
|
12
21
|
Dir["models/*.rb"].each {|file| require file }
|
metadata
CHANGED
@@ -1,16 +1,16 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: active_model_otp
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 2.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Guillermo Iguaran
|
8
8
|
- Roberto Miranda
|
9
9
|
- Heapsource
|
10
|
-
autorequire:
|
10
|
+
autorequire:
|
11
11
|
bindir: bin
|
12
12
|
cert_chain: []
|
13
|
-
date:
|
13
|
+
date: 2021-05-30 00:00:00.000000000 Z
|
14
14
|
dependencies:
|
15
15
|
- !ruby/object:Gem::Dependency
|
16
16
|
name: activemodel
|
@@ -30,30 +30,30 @@ dependencies:
|
|
30
30
|
name: rotp
|
31
31
|
requirement: !ruby/object:Gem::Requirement
|
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
|
-
name:
|
44
|
+
name: activerecord
|
45
45
|
requirement: !ruby/object:Gem::Requirement
|
46
46
|
requirements:
|
47
|
-
- - "
|
47
|
+
- - ">="
|
48
48
|
- !ruby/object:Gem::Version
|
49
|
-
version: '
|
49
|
+
version: '0'
|
50
50
|
type: :development
|
51
51
|
prerelease: false
|
52
52
|
version_requirements: !ruby/object:Gem::Requirement
|
53
53
|
requirements:
|
54
|
-
- - "
|
54
|
+
- - ">="
|
55
55
|
- !ruby/object:Gem::Version
|
56
|
-
version: '
|
56
|
+
version: '0'
|
57
57
|
- !ruby/object:Gem::Dependency
|
58
58
|
name: rake
|
59
59
|
requirement: !ruby/object:Gem::Requirement
|
@@ -82,8 +82,36 @@ dependencies:
|
|
82
82
|
- - "~>"
|
83
83
|
- !ruby/object:Gem::Version
|
84
84
|
version: 5.4.2
|
85
|
-
|
86
|
-
|
85
|
+
- !ruby/object:Gem::Dependency
|
86
|
+
name: appraisal
|
87
|
+
requirement: !ruby/object:Gem::Requirement
|
88
|
+
requirements:
|
89
|
+
- - ">="
|
90
|
+
- !ruby/object:Gem::Version
|
91
|
+
version: '0'
|
92
|
+
type: :development
|
93
|
+
prerelease: false
|
94
|
+
version_requirements: !ruby/object:Gem::Requirement
|
95
|
+
requirements:
|
96
|
+
- - ">="
|
97
|
+
- !ruby/object:Gem::Version
|
98
|
+
version: '0'
|
99
|
+
- !ruby/object:Gem::Dependency
|
100
|
+
name: sqlite3
|
101
|
+
requirement: !ruby/object:Gem::Requirement
|
102
|
+
requirements:
|
103
|
+
- - ">="
|
104
|
+
- !ruby/object:Gem::Version
|
105
|
+
version: '0'
|
106
|
+
type: :development
|
107
|
+
prerelease: false
|
108
|
+
version_requirements: !ruby/object:Gem::Requirement
|
109
|
+
requirements:
|
110
|
+
- - ">="
|
111
|
+
- !ruby/object:Gem::Version
|
112
|
+
version: '0'
|
113
|
+
description: Adds methods to set and authenticate against one time passwords 2FA(Two
|
114
|
+
factor Authentication). Inspired in AM::SecurePassword"
|
87
115
|
email:
|
88
116
|
- guilleiguaran@gmail.com
|
89
117
|
- rjmaltamar@gmail.com
|
@@ -94,24 +122,35 @@ extra_rdoc_files: []
|
|
94
122
|
files:
|
95
123
|
- ".gitignore"
|
96
124
|
- ".travis.yml"
|
125
|
+
- Appraisals
|
97
126
|
- CHANGELOG.md
|
98
127
|
- Gemfile
|
99
128
|
- LICENSE.txt
|
100
129
|
- README.md
|
101
130
|
- Rakefile
|
102
131
|
- active_model_otp.gemspec
|
132
|
+
- gemfiles/rails_4.2.gemfile
|
133
|
+
- gemfiles/rails_5.0.gemfile
|
134
|
+
- gemfiles/rails_5.1.gemfile
|
135
|
+
- gemfiles/rails_5.2.gemfile
|
136
|
+
- gemfiles/rails_6.0.gemfile
|
137
|
+
- gemfiles/rails_6.1.gemfile
|
103
138
|
- lib/active_model/one_time_password.rb
|
104
139
|
- lib/active_model/otp/version.rb
|
105
140
|
- lib/active_model_otp.rb
|
141
|
+
- test/models/activerecord_user.rb
|
142
|
+
- test/models/member.rb
|
143
|
+
- test/models/opt_in_two_factor.rb
|
106
144
|
- test/models/user.rb
|
107
145
|
- test/models/visitor.rb
|
108
146
|
- test/one_time_password_test.rb
|
147
|
+
- test/schema.rb
|
109
148
|
- test/test_helper.rb
|
110
149
|
homepage: ''
|
111
150
|
licenses:
|
112
151
|
- MIT
|
113
152
|
metadata: {}
|
114
|
-
post_install_message:
|
153
|
+
post_install_message:
|
115
154
|
rdoc_options: []
|
116
155
|
require_paths:
|
117
156
|
- lib
|
@@ -119,20 +158,23 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
119
158
|
requirements:
|
120
159
|
- - ">="
|
121
160
|
- !ruby/object:Gem::Version
|
122
|
-
version: '
|
161
|
+
version: '2.3'
|
123
162
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
124
163
|
requirements:
|
125
164
|
- - ">="
|
126
165
|
- !ruby/object:Gem::Version
|
127
166
|
version: '0'
|
128
167
|
requirements: []
|
129
|
-
|
130
|
-
|
131
|
-
signing_key:
|
168
|
+
rubygems_version: 3.0.3
|
169
|
+
signing_key:
|
132
170
|
specification_version: 4
|
133
171
|
summary: Adds methods to set and authenticate against one time passwords.
|
134
172
|
test_files:
|
173
|
+
- test/models/activerecord_user.rb
|
174
|
+
- test/models/member.rb
|
175
|
+
- test/models/opt_in_two_factor.rb
|
135
176
|
- test/models/user.rb
|
136
177
|
- test/models/visitor.rb
|
137
178
|
- test/one_time_password_test.rb
|
179
|
+
- test/schema.rb
|
138
180
|
- test/test_helper.rb
|