active_model_otp 1.2.0 → 2.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/.github/workflows/active_model_otp.yml +43 -0
- data/.gitignore +1 -0
- data/Appraisals +36 -0
- data/CHANGELOG.md +1 -17
- data/LICENSE.txt +1 -1
- data/README.md +93 -14
- 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 +148 -44
- data/lib/active_model/otp/version.rb +1 -1
- data/test/models/activerecord_user.rb +3 -0
- data/test/models/default_interval_user.rb +5 -0
- data/test/models/interval_user.rb +5 -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 +134 -25
- data/test/schema.rb +27 -0
- data/test/test_helper.rb +9 -0
- metadata +66 -20
- data/.travis.yml +0 -10
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 2a57a05fda7ae2023dc877a96228e00e5dbca948bc8d17c1058232a42086e285
|
4
|
+
data.tar.gz: 9d3a965117edbf33fb77585126be84e2fd44b613bbbc435c74627b198b87ee5f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 847accabdd4eff2942a917f497197b3be90bb5cf9e9caefb0cd753635994e3bb8b730aab0d9387aa13c4e929793816333d094b620037a8f9c002a7cc3f3ebdcb
|
7
|
+
data.tar.gz: 6a47a8a378bdb2d9155a165e503f672b5c57576c7f9d4fdef271767422a2d9804a141252707cb0181b02c36a91024d1867b9114cdbc95e475c2e4b292f750b65
|
@@ -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/.gitignore
CHANGED
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,17 +1 @@
|
|
1
|
-
|
2
|
-
- Added Counter based OTP (HOTP) (@ResultsMayVary ) https://github.com/heapsource/active_model_otp/pull/19
|
3
|
-
- Adding options to provisioning uri, so we can include issuer (@doon) https://github.com/heapsource/active_model_otp/pull/15
|
4
|
-
|
5
|
-
#v1.1.0
|
6
|
-
- Add function to re-geterante the OTP secret (@TikiTDO) https://github.com/heapsource/active_model_otp/pull/14
|
7
|
-
- Added option to pass OTP length (@shivanibhanwal) https://github.com/heapsource/active_model_otp/pull/13
|
8
|
-
|
9
|
-
#v1.0.0
|
10
|
-
- Avoid overriding predefined otp_column value when initializing resource (Ilan Stern) https://github.com/heapsource/active_model_otp/pull/10
|
11
|
-
- Pad OTP codes with less than 6 digits (Johan Brissmyr) https://github.com/heapsource/active_model_otp/pull/7
|
12
|
-
- Get rid of deprecation warnings in Rails 4.1 (Nick DeMonner)
|
13
|
-
|
14
|
-
#v0.1.0
|
15
|
-
- OTP codes can be in 5 or 6 digits (André Luis Leal Cardoso Junior)
|
16
|
-
- Require 'cgi', rotp needs it for encoding parameters (André Luis Leal Cardoso Junior)
|
17
|
-
- 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,12 +1,16 @@
|
|
1
1
|
[![Build Status](https://travis-ci.org/heapsource/active_model_otp.png)](https://travis-ci.org/heapsource/active_model_otp)
|
2
2
|
[![Gem Version](https://badge.fury.io/rb/active_model_otp.svg)](http://badge.fury.io/rb/active_model_otp)
|
3
|
-
[![
|
4
|
-
[![Code Climate](https://codeclimate.com/github/heapsource/active_model_otp/badges/gpa.svg)](https://codeclimate.com/github/heapsource/active_model_otp)
|
3
|
+
[![Reviewed by Hound](https://img.shields.io/badge/Reviewed_by-Hound-8E64B0.svg)](https://houndci.com)
|
5
4
|
|
6
5
|
|
7
6
|
# ActiveModel::Otp
|
8
7
|
|
9
|
-
**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
|
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
|
10
14
|
|
11
15
|
## Installation
|
12
16
|
|
@@ -36,27 +40,27 @@ rails g migration AddOtpSecretKeyToUsers otp_secret_key:string
|
|
36
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.
|
37
41
|
|
38
42
|
```ruby
|
39
|
-
class User <
|
43
|
+
class User < ApplicationRecord
|
40
44
|
has_one_time_password
|
41
45
|
end
|
42
46
|
```
|
43
47
|
|
44
48
|
Note: If you're adding this to an existing user model you'll need to generate *otp_secret_key* with a migration like:
|
45
49
|
```ruby
|
46
|
-
User.
|
50
|
+
User.find_each { |user| user.update_attribute(:otp_secret_key, User.otp_random_secret) }
|
47
51
|
```
|
48
52
|
|
49
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.
|
50
54
|
|
51
55
|
```ruby
|
52
|
-
class User <
|
56
|
+
class User < ApplicationRecord
|
53
57
|
has_one_time_password column_name: :my_otp_secret_column, length: 4
|
54
58
|
end
|
55
59
|
```
|
56
60
|
|
57
61
|
## Usage
|
58
62
|
|
59
|
-
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](
|
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.
|
60
64
|
|
61
65
|
The otp_secret_key is saved automatically when an object is created,
|
62
66
|
|
@@ -76,9 +80,6 @@ user.otp_code # => '850738'
|
|
76
80
|
|
77
81
|
# Override current time
|
78
82
|
user.otp_code(time: Time.now + 3600) # => '317438'
|
79
|
-
|
80
|
-
# Don't zero-pad to six digits
|
81
|
-
user.otp_code(padding: false) # => '438'
|
82
83
|
```
|
83
84
|
|
84
85
|
### Authenticating using a code
|
@@ -108,10 +109,15 @@ rails g migration AddCounterForOtpToUsers otp_counter:integer
|
|
108
109
|
create db/migrate/20130707010931_add_counter_for_otp_to_users.rb
|
109
110
|
```
|
110
111
|
|
112
|
+
Set default value for otp_counter to 0.
|
113
|
+
```ruby
|
114
|
+
change_column :users, :otp_counter, :integer, default: 0
|
115
|
+
```
|
116
|
+
|
111
117
|
In addition set the counter flag option to true
|
112
118
|
|
113
119
|
```ruby
|
114
|
-
class User <
|
120
|
+
class User < ApplicationRecord
|
115
121
|
has_one_time_password counter_based: true
|
116
122
|
end
|
117
123
|
```
|
@@ -119,7 +125,7 @@ end
|
|
119
125
|
And for a custom counter column
|
120
126
|
|
121
127
|
```ruby
|
122
|
-
class User <
|
128
|
+
class User < ApplicationRecord
|
123
129
|
has_one_time_password counter_based: true, counter_column_name: :my_otp_secret_counter_column
|
124
130
|
end
|
125
131
|
```
|
@@ -144,6 +150,51 @@ user.otp_code(auto_increment: true) # => '002811'
|
|
144
150
|
user.otp_code # => '002811'
|
145
151
|
```
|
146
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
|
+
|
147
198
|
## Google Authenticator Compatible
|
148
199
|
|
149
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.
|
@@ -154,10 +205,36 @@ user.provisioning_uri # => 'otpauth://totp/hello@heapsource.com?secret=2z6hxkdwi
|
|
154
205
|
|
155
206
|
# Use a custom field to generate the provisioning_url
|
156
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'
|
157
212
|
```
|
158
213
|
|
159
214
|
This can then be rendered as a QR Code which can be scanned and added to the users list of OTP credentials.
|
160
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
|
+
|
161
238
|
### Working example
|
162
239
|
|
163
240
|
Scan the following barcode with your phone, using Google Authenticator
|
@@ -168,6 +245,7 @@ Now run the following and compare the output
|
|
168
245
|
|
169
246
|
```ruby
|
170
247
|
require "active_model_otp"
|
248
|
+
|
171
249
|
class User
|
172
250
|
extend ActiveModel::Callbacks
|
173
251
|
include ActiveModel::Validations
|
@@ -178,6 +256,7 @@ class User
|
|
178
256
|
|
179
257
|
has_one_time_password
|
180
258
|
end
|
259
|
+
|
181
260
|
user = User.new
|
182
261
|
user.email = 'roberto@heapsource.com'
|
183
262
|
user.otp_secret_key = "2z6hxkdwi3uvrnpn"
|
@@ -187,10 +266,10 @@ puts "Current code #{user.otp_code}"
|
|
187
266
|
**Note:** otp_secret_key must be generated using RFC 3548 base32 key strings (for compatilibity with google authenticator)
|
188
267
|
|
189
268
|
### Useful Examples
|
190
|
-
|
269
|
+
- [Drifting Ruby Tutorial](https://www.driftingruby.com/episodes/two-factor-authentication)
|
191
270
|
- [Generate QR code with rqrcode gem](https://github.com/heapsource/active_model_otp/wiki/Generate-QR-code-with-rqrcode-gem)
|
192
271
|
- Generating QR Code with Google Charts API
|
193
|
-
- [
|
272
|
+
- [Sending code via SMS with Twilio](https://github.com/heapsource/active_model_otp/wiki/Send-code-via-Twilio-SMS)
|
194
273
|
- [Using with Mongoid](https://github.com/heapsource/active_model_otp/wiki/Using-with-Mongoid)
|
195
274
|
|
196
275
|
## 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
|
@@ -2,25 +2,50 @@ module ActiveModel
|
|
2
2
|
module OneTimePassword
|
3
3
|
extend ActiveSupport::Concern
|
4
4
|
|
5
|
-
|
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
|
6
12
|
|
13
|
+
module ClassMethods
|
7
14
|
def has_one_time_password(options = {})
|
8
|
-
cattr_accessor :otp_column_name, :otp_counter_column_name
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
self.
|
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
|
+
)
|
15
28
|
self.otp_counter_column_name = (
|
16
|
-
options[:counter_column_name] ||
|
17
|
-
|
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
|
+
)
|
18
42
|
|
19
43
|
include InstanceMethodsOnActivation
|
20
44
|
|
21
|
-
before_create do
|
45
|
+
before_create(**options.slice(:if, :unless)) do
|
22
46
|
self.otp_regenerate_secret if !otp_column
|
23
47
|
self.otp_regenerate_counter if otp_counter_based && !otp_counter
|
48
|
+
otp_regenerate_backup_codes if backup_codes_enabled?
|
24
49
|
end
|
25
50
|
|
26
51
|
if respond_to?(:attributes_protected_by_default)
|
@@ -29,11 +54,17 @@ module ActiveModel
|
|
29
54
|
end
|
30
55
|
end
|
31
56
|
end
|
57
|
+
|
58
|
+
# Defaults to 160 bit long secret
|
59
|
+
# (meaning a 32 character long base32 secret)
|
60
|
+
def otp_random_secret(length = 20)
|
61
|
+
ROTP::Base32.random(length)
|
62
|
+
end
|
32
63
|
end
|
33
64
|
|
34
65
|
module InstanceMethodsOnActivation
|
35
66
|
def otp_regenerate_secret
|
36
|
-
self.otp_column =
|
67
|
+
self.otp_column = self.class.otp_random_secret
|
37
68
|
end
|
38
69
|
|
39
70
|
def otp_regenerate_counter
|
@@ -41,67 +72,140 @@ module ActiveModel
|
|
41
72
|
end
|
42
73
|
|
43
74
|
def authenticate_otp(code, options = {})
|
75
|
+
return true if backup_codes_enabled? && authenticate_backup_code(code)
|
76
|
+
|
44
77
|
if otp_counter_based
|
45
|
-
|
46
|
-
result = hotp.verify(code, otp_counter)
|
47
|
-
if result && options[:auto_increment]
|
48
|
-
self.otp_counter += 1
|
49
|
-
save if !new_record?
|
50
|
-
end
|
51
|
-
result
|
78
|
+
otp_counter == authenticate_hotp(code, options)
|
52
79
|
else
|
53
|
-
|
54
|
-
if drift = options[:drift]
|
55
|
-
totp.verify_with_drift(code, drift)
|
56
|
-
else
|
57
|
-
totp.verify(code)
|
58
|
-
end
|
80
|
+
authenticate_totp(code, options).present?
|
59
81
|
end
|
60
82
|
end
|
61
83
|
|
62
84
|
def otp_code(options = {})
|
63
85
|
if otp_counter_based
|
64
|
-
|
65
|
-
self.otp_counter += 1
|
66
|
-
save if !new_record?
|
67
|
-
end
|
68
|
-
ROTP::HOTP.new(otp_column, digits: otp_digits).at(self.otp_counter)
|
86
|
+
hotp_code(options)
|
69
87
|
else
|
70
|
-
|
71
|
-
time = options.fetch(:time, Time.now)
|
72
|
-
padding = options.fetch(:padding, true)
|
73
|
-
else
|
74
|
-
time = options
|
75
|
-
padding = true
|
76
|
-
end
|
77
|
-
ROTP::TOTP.new(otp_column, digits: otp_digits).at(time, padding)
|
88
|
+
totp_code(options)
|
78
89
|
end
|
79
90
|
end
|
80
91
|
|
81
92
|
def provisioning_uri(account = nil, options = {})
|
82
93
|
account ||= self.email if self.respond_to?(:email)
|
94
|
+
account ||= ""
|
83
95
|
|
84
96
|
if otp_counter_based
|
85
|
-
ROTP::HOTP
|
97
|
+
ROTP::HOTP
|
98
|
+
.new(otp_column, options)
|
99
|
+
.provisioning_uri(account, self.otp_counter)
|
86
100
|
else
|
87
|
-
ROTP::TOTP
|
101
|
+
ROTP::TOTP
|
102
|
+
.new(otp_column, options)
|
103
|
+
.provisioning_uri(account)
|
88
104
|
end
|
89
105
|
end
|
90
106
|
|
91
107
|
def otp_column
|
92
|
-
self.
|
108
|
+
self.public_send(self.class.otp_column_name)
|
93
109
|
end
|
94
110
|
|
95
111
|
def otp_column=(attr)
|
96
|
-
self.
|
112
|
+
self.public_send("#{self.class.otp_column_name}=", attr)
|
97
113
|
end
|
98
114
|
|
99
115
|
def otp_counter
|
100
|
-
self.
|
116
|
+
if self.class.otp_counter_column_name != "otp_counter"
|
117
|
+
self.public_send(self.class.otp_counter_column_name)
|
118
|
+
else
|
119
|
+
super
|
120
|
+
end
|
101
121
|
end
|
102
122
|
|
103
123
|
def otp_counter=(attr)
|
104
|
-
self.
|
124
|
+
if self.class.otp_counter_column_name != "otp_counter"
|
125
|
+
self.public_send("#{self.class.otp_counter_column_name}=", attr)
|
126
|
+
else
|
127
|
+
super
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
def serializable_hash(options = nil)
|
132
|
+
options ||= {}
|
133
|
+
options[:except] = Array(options[:except])
|
134
|
+
options[:except] << self.class.otp_column_name
|
135
|
+
super(options)
|
136
|
+
end
|
137
|
+
|
138
|
+
def otp_regenerate_backup_codes
|
139
|
+
otp = ROTP::OTP.new(otp_column)
|
140
|
+
backup_codes = Array.new(self.class.otp_backup_codes_count) do
|
141
|
+
otp.generate_otp((SecureRandom.random_number(9e5) + 1e5).to_i)
|
142
|
+
end
|
143
|
+
|
144
|
+
public_send("#{self.class.otp_backup_codes_column_name}=", backup_codes)
|
145
|
+
end
|
146
|
+
|
147
|
+
def backup_codes_enabled?
|
148
|
+
self.class.attribute_method?(self.class.otp_backup_codes_column_name)
|
149
|
+
end
|
150
|
+
|
151
|
+
private
|
152
|
+
|
153
|
+
def authenticate_hotp(code, options = {})
|
154
|
+
hotp = ROTP::HOTP.new(otp_column, digits: otp_digits)
|
155
|
+
result = hotp.verify(code, otp_counter)
|
156
|
+
if result && options[:auto_increment]
|
157
|
+
self.otp_counter += 1
|
158
|
+
save if respond_to?(:changed?) && !new_record?
|
159
|
+
end
|
160
|
+
result
|
161
|
+
end
|
162
|
+
|
163
|
+
def authenticate_totp(code, options = {})
|
164
|
+
totp = ROTP::TOTP.new(
|
165
|
+
otp_column,
|
166
|
+
digits: otp_digits,
|
167
|
+
interval: otp_interval
|
168
|
+
)
|
169
|
+
if (drift = options[:drift])
|
170
|
+
totp.verify(code, drift_behind: drift)
|
171
|
+
else
|
172
|
+
totp.verify(code)
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
def hotp_code(options = {})
|
177
|
+
if options[:auto_increment]
|
178
|
+
self.otp_counter += 1
|
179
|
+
save if respond_to?(:changed?) && !new_record?
|
180
|
+
end
|
181
|
+
ROTP::HOTP.new(otp_column, digits: otp_digits).at(otp_counter)
|
182
|
+
end
|
183
|
+
|
184
|
+
def totp_code(options = {})
|
185
|
+
time = if options.is_a?(Hash)
|
186
|
+
options.fetch(:time, Time.now)
|
187
|
+
else
|
188
|
+
options
|
189
|
+
end
|
190
|
+
ROTP::TOTP.new(
|
191
|
+
otp_column,
|
192
|
+
digits: otp_digits,
|
193
|
+
interval: otp_interval
|
194
|
+
).at(time)
|
195
|
+
end
|
196
|
+
|
197
|
+
def authenticate_backup_code(code)
|
198
|
+
backup_codes_column_name = self.class.otp_backup_codes_column_name
|
199
|
+
backup_codes = public_send(backup_codes_column_name)
|
200
|
+
return false unless backup_codes.present? && backup_codes.include?(code)
|
201
|
+
|
202
|
+
if self.class.otp_one_time_backup_codes
|
203
|
+
backup_codes.delete(code)
|
204
|
+
public_send("#{backup_codes_column_name}=", backup_codes)
|
205
|
+
save if respond_to?(:changed?) && !new_record?
|
206
|
+
end
|
207
|
+
|
208
|
+
true
|
105
209
|
end
|
106
210
|
end
|
107
211
|
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,38 +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
|
62
120
|
end
|
63
121
|
|
64
122
|
def test_provisioning_uri_with_options
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
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
|
69
150
|
end
|
70
151
|
|
71
152
|
def test_regenerate_otp
|
@@ -73,4 +154,32 @@ class OtpTest < MiniTest::Unit::TestCase
|
|
73
154
|
@user.otp_regenerate_secret
|
74
155
|
assert secret != @user.otp_column
|
75
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
|
165
|
+
|
166
|
+
def test_otp_interval
|
167
|
+
@interval_user = IntervalUser.new
|
168
|
+
@interval_user.email = 'roberto@heapsource.com'
|
169
|
+
@interval_user.run_callbacks :create
|
170
|
+
otp_code = @interval_user.otp_code
|
171
|
+
2.times { assert_match(otp_code, @interval_user.otp_code) }
|
172
|
+
sleep 5
|
173
|
+
refute_match(otp_code, @interval_user.otp_code)
|
174
|
+
end
|
175
|
+
|
176
|
+
def test_otp_default_interval
|
177
|
+
@default_interval_user = DefaultIntervalUser.new
|
178
|
+
@default_interval_user.email = 'roberto@heapsource.com'
|
179
|
+
@default_interval_user.run_callbacks :create
|
180
|
+
otp_code = @default_interval_user.otp_code
|
181
|
+
2.times { assert_match(otp_code, @default_interval_user.otp_code) }
|
182
|
+
sleep 5
|
183
|
+
assert_match(otp_code, @default_interval_user.otp_code)
|
184
|
+
end
|
76
185
|
end
|
data/test/schema.rb
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
ActiveRecord::Schema.define do
|
2
|
+
self.verbose = false
|
3
|
+
|
4
|
+
create_table :activerecord_users, force: true do |t|
|
5
|
+
t.string :key
|
6
|
+
t.string :email
|
7
|
+
t.integer :otp_counter
|
8
|
+
t.string :otp_secret_key
|
9
|
+
t.timestamps
|
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
|
27
|
+
end
|
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.3.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-06-22 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
|
@@ -92,26 +120,39 @@ executables: []
|
|
92
120
|
extensions: []
|
93
121
|
extra_rdoc_files: []
|
94
122
|
files:
|
123
|
+
- ".github/workflows/active_model_otp.yml"
|
95
124
|
- ".gitignore"
|
96
|
-
-
|
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/default_interval_user.rb
|
143
|
+
- test/models/interval_user.rb
|
144
|
+
- test/models/member.rb
|
145
|
+
- test/models/opt_in_two_factor.rb
|
106
146
|
- test/models/user.rb
|
107
147
|
- test/models/visitor.rb
|
108
148
|
- test/one_time_password_test.rb
|
149
|
+
- test/schema.rb
|
109
150
|
- test/test_helper.rb
|
110
151
|
homepage: ''
|
111
152
|
licenses:
|
112
153
|
- MIT
|
113
154
|
metadata: {}
|
114
|
-
post_install_message:
|
155
|
+
post_install_message:
|
115
156
|
rdoc_options: []
|
116
157
|
require_paths:
|
117
158
|
- lib
|
@@ -119,20 +160,25 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
119
160
|
requirements:
|
120
161
|
- - ">="
|
121
162
|
- !ruby/object:Gem::Version
|
122
|
-
version: '
|
163
|
+
version: '2.3'
|
123
164
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
124
165
|
requirements:
|
125
166
|
- - ">="
|
126
167
|
- !ruby/object:Gem::Version
|
127
168
|
version: '0'
|
128
169
|
requirements: []
|
129
|
-
|
130
|
-
|
131
|
-
signing_key:
|
170
|
+
rubygems_version: 3.0.3
|
171
|
+
signing_key:
|
132
172
|
specification_version: 4
|
133
173
|
summary: Adds methods to set and authenticate against one time passwords.
|
134
174
|
test_files:
|
175
|
+
- test/models/activerecord_user.rb
|
176
|
+
- test/models/default_interval_user.rb
|
177
|
+
- test/models/interval_user.rb
|
178
|
+
- test/models/member.rb
|
179
|
+
- test/models/opt_in_two_factor.rb
|
135
180
|
- test/models/user.rb
|
136
181
|
- test/models/visitor.rb
|
137
182
|
- test/one_time_password_test.rb
|
183
|
+
- test/schema.rb
|
138
184
|
- test/test_helper.rb
|