active_model_otp 0.1.0 → 2.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +1 -0
- data/.travis.yml +22 -7
- data/Appraisals +25 -0
- data/CHANGELOG.md +1 -0
- data/LICENSE.txt +1 -1
- data/README.md +101 -23
- data/active_model_otp.gemspec +13 -4
- data/gemfiles/rails_4.2.gemfile +7 -0
- data/gemfiles/rails_5.0.gemfile +8 -0
- data/gemfiles/rails_5.1.gemfile +8 -0
- data/gemfiles/rails_5.2.gemfile +8 -0
- data/gemfiles/rails_6.0.gemfile +10 -0
- data/lib/active_model/one_time_password.rb +86 -13
- data/lib/active_model/otp/version.rb +1 -1
- data/lib/active_model_otp.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 +5 -1
- data/test/models/visitor.rb +1 -2
- data/test/one_time_password_test.rb +83 -6
- data/test/schema.rb +11 -0
- data/test/test_helper.rb +10 -1
- metadata +73 -43
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 973b2897bf7c434844a9ec652c599705fbfe6def2da99b20302059047ba960d1
|
4
|
+
data.tar.gz: 4ac0735f61e0d74109ec39d4a6b7504028a76d2ab74f31bde8e90dbd9625a61a
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 53612a20dc401b03c051c48450e7161388fed1ab5d2c7d43ebcf12dbec82ca8f12e05d033237f505257d79c4e3ba9861fe5bdae943280e0b625ebb9a0bb95e2d
|
7
|
+
data.tar.gz: 2cbb80548cccb92ff3ed398671c65aa600adc32de84a95b91287de67a4a7fbac45cf97d2d1d311bc1fc141cdb630a1a4e803a3785e79449ec00fea8ce3db5f2c
|
data/.gitignore
CHANGED
data/.travis.yml
CHANGED
@@ -1,11 +1,26 @@
|
|
1
1
|
rvm:
|
2
|
-
-
|
3
|
-
- 2.
|
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
|
4
13
|
matrix:
|
5
|
-
|
6
|
-
- rvm:
|
7
|
-
|
8
|
-
- rvm:
|
9
|
-
|
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"
|
10
25
|
notifications:
|
11
26
|
email: false
|
data/Appraisals
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
appraise "rails-4.2" do
|
2
|
+
gem "activemodel", "~> 4.2"
|
3
|
+
end
|
4
|
+
|
5
|
+
appraise "rails-5.0" do
|
6
|
+
gem "activemodel", "~> 5.0"
|
7
|
+
gem "activemodel-serializers-xml"
|
8
|
+
end
|
9
|
+
|
10
|
+
appraise "rails-5.1" do
|
11
|
+
gem "activemodel", "~> 5.1"
|
12
|
+
gem "activemodel-serializers-xml"
|
13
|
+
end
|
14
|
+
|
15
|
+
appraise "rails-5.2" do
|
16
|
+
gem "activemodel", "~> 5.2"
|
17
|
+
gem "activemodel-serializers-xml"
|
18
|
+
end
|
19
|
+
|
20
|
+
appraise "rails-6.0" do
|
21
|
+
gem "activerecord", "~> 6.0"
|
22
|
+
gem "activemodel", "~> 6.0"
|
23
|
+
gem "activemodel-serializers-xml"
|
24
|
+
gem "sqlite3", "~> 1.4"
|
25
|
+
end
|
data/CHANGELOG.md
ADDED
@@ -0,0 +1 @@
|
|
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) 5.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,34 +37,49 @@ 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
|
|
48
|
+
Note: If you're adding this to an existing user model you'll need to generate *otp_secret_key* with a migration like:
|
49
|
+
```ruby
|
50
|
+
User.find_each { |user| user.update_attribute(:otp_secret_key, User.otp_random_secret) }
|
51
|
+
```
|
40
52
|
|
41
|
-
|
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.
|
42
54
|
|
43
|
-
|
55
|
+
```ruby
|
56
|
+
class User < ApplicationRecord
|
57
|
+
has_one_time_password column_name: :my_otp_secret_column, length: 4
|
58
|
+
end
|
59
|
+
```
|
60
|
+
|
61
|
+
## Usage
|
62
|
+
|
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.
|
44
64
|
|
45
|
-
The otp_secret_key is saved automatically when
|
65
|
+
The otp_secret_key is saved automatically when an object is created,
|
46
66
|
|
47
67
|
```ruby
|
48
68
|
user = User.create(email: "hello@heapsource.com")
|
49
69
|
user.otp_secret_key
|
50
|
-
=> "jt3gdd2qm6su5iqh"
|
70
|
+
=> "jt3gdd2qm6su5iqh"
|
51
71
|
```
|
52
72
|
|
53
|
-
**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
|
54
74
|
|
55
|
-
### Getting current code (
|
75
|
+
### Getting current code (e.g. to send via SMS)
|
56
76
|
```ruby
|
57
77
|
user.otp_code # => '186522'
|
58
78
|
sleep 30
|
59
79
|
user.otp_code # => '850738'
|
80
|
+
|
81
|
+
# Override current time
|
82
|
+
user.otp_code(time: Time.now + 3600) # => '317438'
|
60
83
|
```
|
61
84
|
|
62
85
|
### Authenticating using a code
|
@@ -75,19 +98,75 @@ sleep 30 # lets wait again
|
|
75
98
|
user.authenticate_otp('186522', drift: 60) # => true
|
76
99
|
```
|
77
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
|
+
|
78
153
|
## Google Authenticator Compatible
|
79
154
|
|
80
155
|
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.
|
81
156
|
|
82
157
|
```ruby
|
83
|
-
# Use
|
84
|
-
user.
|
158
|
+
# Use your user's email address to generate the provisioning_url
|
159
|
+
user.provisioning_uri # => 'otpauth://totp/hello@heapsource.com?secret=2z6hxkdwi3uvrnpn'
|
160
|
+
|
161
|
+
# Use a custom field to generate the provisioning_url
|
162
|
+
user.provisioning_uri("hello") # => 'otpauth://totp/hello?secret=2z6hxkdwi3uvrnpn'
|
85
163
|
|
86
|
-
#
|
87
|
-
|
164
|
+
# You can customize the generated url, by passing a hash of Options
|
165
|
+
# `:issuer` lets you set the Issuer name in Google Authenticator, so it doesn't show as a blank entry.
|
166
|
+
user.provisioning_uri(nil, issuer: 'MYAPP') #=> 'otpauth://totp/hello@heapsource.com?secret=2z6hxkdwi3uvrnpn&issuer=MYAPP'
|
88
167
|
```
|
89
168
|
|
90
|
-
This can then be rendered as a QR Code which can
|
169
|
+
This can then be rendered as a QR Code which can be scanned and added to the users list of OTP credentials.
|
91
170
|
|
92
171
|
### Working example
|
93
172
|
|
@@ -99,6 +178,7 @@ Now run the following and compare the output
|
|
99
178
|
|
100
179
|
```ruby
|
101
180
|
require "active_model_otp"
|
181
|
+
|
102
182
|
class User
|
103
183
|
extend ActiveModel::Callbacks
|
104
184
|
include ActiveModel::Validations
|
@@ -109,6 +189,7 @@ class User
|
|
109
189
|
|
110
190
|
has_one_time_password
|
111
191
|
end
|
192
|
+
|
112
193
|
user = User.new
|
113
194
|
user.email = 'roberto@heapsource.com'
|
114
195
|
user.otp_secret_key = "2z6hxkdwi3uvrnpn"
|
@@ -118,14 +199,11 @@ puts "Current code #{user.otp_code}"
|
|
118
199
|
**Note:** otp_secret_key must be generated using RFC 3548 base32 key strings (for compatilibity with google authenticator)
|
119
200
|
|
120
201
|
### Useful Examples
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
#### Sendind code via email with Twilio
|
127
|
-
|
128
|
-
#### Using with Mongoid
|
202
|
+
- [Drifting Ruby Tutorial](https://www.driftingruby.com/episodes/two-factor-authentication)
|
203
|
+
- [Generate QR code with rqrcode gem](https://github.com/heapsource/active_model_otp/wiki/Generate-QR-code-with-rqrcode-gem)
|
204
|
+
- Generating QR Code with Google Charts API
|
205
|
+
- [Sending code via SMS with Twilio](https://github.com/heapsource/active_model_otp/wiki/Send-code-via-Twilio-SMS)
|
206
|
+
- [Using with Mongoid](https://github.com/heapsource/active_model_otp/wiki/Using-with-Mongoid)
|
129
207
|
|
130
208
|
## Contributing
|
131
209
|
|
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"
|
@@ -17,11 +17,20 @@ 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
|
+
|
21
|
+
spec.required_ruby_version = ">= 2.3"
|
20
22
|
|
21
23
|
spec.add_dependency "activemodel"
|
22
|
-
spec.add_dependency "rotp"
|
24
|
+
spec.add_dependency "rotp", "~> 5.0.0"
|
23
25
|
|
24
|
-
spec.add_development_dependency "
|
26
|
+
spec.add_development_dependency "activerecord"
|
25
27
|
spec.add_development_dependency "rake"
|
26
|
-
spec.add_development_dependency "minitest"
|
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", "~> 1.3.6"
|
35
|
+
end
|
27
36
|
end
|
@@ -4,49 +4,122 @@ module ActiveModel
|
|
4
4
|
|
5
5
|
module ClassMethods
|
6
6
|
def has_one_time_password(options = {})
|
7
|
+
cattr_accessor :otp_column_name, :otp_counter_column_name
|
8
|
+
class_attribute :otp_digits, :otp_counter_based
|
7
9
|
|
8
|
-
cattr_accessor :otp_column_name
|
9
10
|
self.otp_column_name = (options[:column_name] || "otp_secret_key").to_s
|
11
|
+
self.otp_digits = options[:length] || 6
|
12
|
+
|
13
|
+
self.otp_counter_based = (options[:counter_based] || false)
|
14
|
+
self.otp_counter_column_name = (options[:counter_column_name] || "otp_counter").to_s
|
10
15
|
|
11
16
|
include InstanceMethodsOnActivation
|
12
17
|
|
13
|
-
before_create
|
18
|
+
before_create(options.slice(:if, :unless)) do
|
19
|
+
self.otp_regenerate_secret if !otp_column
|
20
|
+
self.otp_regenerate_counter if otp_counter_based && !otp_counter
|
21
|
+
end
|
14
22
|
|
15
23
|
if respond_to?(:attributes_protected_by_default)
|
16
24
|
def self.attributes_protected_by_default #:nodoc:
|
17
|
-
super + [
|
25
|
+
super + [otp_column_name, otp_counter_column_name]
|
18
26
|
end
|
19
27
|
end
|
20
28
|
end
|
29
|
+
|
30
|
+
# Defaults to 160 bit long secret
|
31
|
+
# (meaning a 32 character long base32 secret)
|
32
|
+
def otp_random_secret(length = 20)
|
33
|
+
ROTP::Base32.random(length)
|
34
|
+
end
|
21
35
|
end
|
22
36
|
|
23
37
|
module InstanceMethodsOnActivation
|
38
|
+
def otp_regenerate_secret
|
39
|
+
self.otp_column = self.class.otp_random_secret
|
40
|
+
end
|
41
|
+
|
42
|
+
def otp_regenerate_counter
|
43
|
+
self.otp_counter = 1
|
44
|
+
end
|
45
|
+
|
24
46
|
def authenticate_otp(code, options = {})
|
25
|
-
|
26
|
-
|
27
|
-
|
47
|
+
if otp_counter_based
|
48
|
+
hotp = ROTP::HOTP.new(otp_column, digits: otp_digits)
|
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
|
28
55
|
else
|
29
|
-
totp.
|
56
|
+
totp = ROTP::TOTP.new(otp_column, digits: otp_digits)
|
57
|
+
if drift = options[:drift]
|
58
|
+
totp.verify(code, drift_behind: drift)
|
59
|
+
else
|
60
|
+
totp.verify(code)
|
61
|
+
end
|
30
62
|
end
|
31
63
|
end
|
32
64
|
|
33
|
-
def otp_code(
|
34
|
-
|
65
|
+
def otp_code(options = {})
|
66
|
+
if otp_counter_based
|
67
|
+
if options[:auto_increment]
|
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)
|
72
|
+
else
|
73
|
+
if options.is_a? Hash
|
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)
|
79
|
+
end
|
35
80
|
end
|
36
81
|
|
37
|
-
def provisioning_uri(account = nil)
|
82
|
+
def provisioning_uri(account = nil, options = {})
|
38
83
|
account ||= self.email if self.respond_to?(:email)
|
39
|
-
|
84
|
+
account ||= ""
|
85
|
+
|
86
|
+
if otp_counter_based
|
87
|
+
ROTP::HOTP.new(otp_column, options).provisioning_uri(account)
|
88
|
+
else
|
89
|
+
ROTP::TOTP.new(otp_column, options).provisioning_uri(account)
|
90
|
+
end
|
40
91
|
end
|
41
92
|
|
42
93
|
def otp_column
|
43
|
-
self.
|
94
|
+
self.public_send(self.class.otp_column_name)
|
44
95
|
end
|
45
96
|
|
46
97
|
def otp_column=(attr)
|
47
|
-
self.
|
98
|
+
self.public_send("#{self.class.otp_column_name}=", attr)
|
48
99
|
end
|
49
100
|
|
101
|
+
def otp_counter
|
102
|
+
if self.class.otp_counter_column_name != "otp_counter"
|
103
|
+
self.public_send(self.class.otp_counter_column_name)
|
104
|
+
else
|
105
|
+
super
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def otp_counter=(attr)
|
110
|
+
if self.class.otp_counter_column_name != "otp_counter"
|
111
|
+
self.public_send("#{self.class.otp_counter_column_name}=", attr)
|
112
|
+
else
|
113
|
+
super
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
def serializable_hash(options = nil)
|
118
|
+
options ||= {}
|
119
|
+
options[:except] = Array(options[:except])
|
120
|
+
options[:except] << self.class.otp_column_name
|
121
|
+
super(options)
|
122
|
+
end
|
50
123
|
end
|
51
124
|
end
|
52
125
|
end
|
data/lib/active_model_otp.rb
CHANGED
@@ -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,5 +1,7 @@
|
|
1
1
|
class User
|
2
2
|
extend ActiveModel::Callbacks
|
3
|
+
include ActiveModel::Serializers::JSON
|
4
|
+
include ActiveModel::Serializers::Xml
|
3
5
|
include ActiveModel::Validations
|
4
6
|
include ActiveModel::OneTimePassword
|
5
7
|
|
@@ -7,5 +9,7 @@ class User
|
|
7
9
|
attr_accessor :otp_secret_key, :email
|
8
10
|
|
9
11
|
has_one_time_password
|
10
|
-
|
12
|
+
def attributes
|
13
|
+
{ "otp_secret_key" => otp_secret_key, "email" => email }
|
14
|
+
end
|
11
15
|
end
|
data/test/models/visitor.rb
CHANGED
@@ -5,9 +5,22 @@ class OtpTest < MiniTest::Unit::TestCase
|
|
5
5
|
@user = User.new
|
6
6
|
@user.email = 'roberto@heapsource.com'
|
7
7
|
@user.run_callbacks :create
|
8
|
+
|
8
9
|
@visitor = Visitor.new
|
9
10
|
@visitor.email = 'roberto@heapsource.com'
|
10
11
|
@visitor.run_callbacks :create
|
12
|
+
|
13
|
+
@member = Member.new
|
14
|
+
@member.email = nil
|
15
|
+
@member.run_callbacks :create
|
16
|
+
|
17
|
+
@ar_user = ActiverecordUser.new
|
18
|
+
@ar_user.email = 'roberto@heapsource.com'
|
19
|
+
@ar_user.run_callbacks :create
|
20
|
+
|
21
|
+
@opt_in = OptInTwoFactor.new
|
22
|
+
@opt_in.email = 'roberto@heapsource.com'
|
23
|
+
@opt_in.run_callbacks :create
|
11
24
|
end
|
12
25
|
|
13
26
|
def test_authenticate_with_otp
|
@@ -18,6 +31,36 @@ class OtpTest < MiniTest::Unit::TestCase
|
|
18
31
|
assert @visitor.authenticate_otp(code)
|
19
32
|
end
|
20
33
|
|
34
|
+
def test_counter_based_otp
|
35
|
+
code = @member.otp_code
|
36
|
+
assert @member.authenticate_otp(code)
|
37
|
+
assert @member.authenticate_otp(code, auto_increment: true)
|
38
|
+
assert !@member.authenticate_otp(code)
|
39
|
+
@member.otp_counter -= 1
|
40
|
+
assert @member.authenticate_otp(code)
|
41
|
+
assert code == @member.otp_code
|
42
|
+
assert code != @member.otp_code(auto_increment: true)
|
43
|
+
end
|
44
|
+
|
45
|
+
def test_counter_based_otp_active_record
|
46
|
+
code = @ar_user.otp_code
|
47
|
+
assert @ar_user.authenticate_otp(code)
|
48
|
+
assert @ar_user.authenticate_otp(code, auto_increment: true)
|
49
|
+
assert !@ar_user.authenticate_otp(code)
|
50
|
+
@ar_user.otp_counter -= 1
|
51
|
+
assert @ar_user.authenticate_otp(code)
|
52
|
+
assert code == @ar_user.otp_code
|
53
|
+
assert code != @ar_user.otp_code(auto_increment: true)
|
54
|
+
end
|
55
|
+
|
56
|
+
def test_opt_in_two_factor
|
57
|
+
assert @opt_in.otp_column.nil?
|
58
|
+
|
59
|
+
@opt_in.otp_regenerate_secret
|
60
|
+
code = @opt_in.otp_code
|
61
|
+
assert @opt_in.authenticate_otp(code)
|
62
|
+
end
|
63
|
+
|
21
64
|
def test_authenticate_with_otp_when_drift_is_allowed
|
22
65
|
code = @user.otp_code(Time.now - 30)
|
23
66
|
assert @user.authenticate_otp(code, drift: 60)
|
@@ -27,17 +70,51 @@ class OtpTest < MiniTest::Unit::TestCase
|
|
27
70
|
end
|
28
71
|
|
29
72
|
def test_otp_code
|
30
|
-
assert_match(
|
31
|
-
assert_match(
|
73
|
+
assert_match(/^\d{6}$/, @user.otp_code.to_s)
|
74
|
+
assert_match(/^\d{4}$/, @visitor.otp_code.to_s)
|
75
|
+
end
|
76
|
+
|
77
|
+
def test_otp_code_with_specific_length
|
78
|
+
assert_match(/^\d{4}$/, @visitor.otp_code(2160).to_s)
|
79
|
+
assert_operator(@visitor.otp_code(2160).to_s.length, :<=, 4)
|
80
|
+
end
|
81
|
+
|
82
|
+
def test_otp_code_without_specific_length
|
83
|
+
assert_match(/^\d{6}$/, @user.otp_code(2160).to_s)
|
84
|
+
assert_operator(@user.otp_code(2160).to_s.length, :<=, 6)
|
32
85
|
end
|
33
86
|
|
34
87
|
def test_provisioning_uri_with_provided_account
|
35
|
-
assert_match %r{otpauth://totp/roberto\?secret=\w{
|
36
|
-
assert_match %r{otpauth://totp/roberto\?secret=\w{
|
88
|
+
assert_match %r{^otpauth://totp/roberto\?secret=\w{32}$}, @user.provisioning_uri("roberto")
|
89
|
+
assert_match %r{^otpauth://totp/roberto\?secret=\w{32}$}, @visitor.provisioning_uri("roberto")
|
90
|
+
assert_match %r{^otpauth://hotp/roberto\?secret=\w{32}&counter=0$}, @member.provisioning_uri("roberto")
|
37
91
|
end
|
38
92
|
|
39
93
|
def test_provisioning_uri_with_email_field
|
40
|
-
assert_match %r{otpauth://totp/roberto@heapsource\.com\?secret=\w{
|
41
|
-
assert_match %r{otpauth://totp/roberto@heapsource\.com\?secret=\w{
|
94
|
+
assert_match %r{^otpauth://totp/roberto@heapsource\.com\?secret=\w{32}$}, @user.provisioning_uri
|
95
|
+
assert_match %r{^otpauth://totp/roberto@heapsource\.com\?secret=\w{32}$}, @visitor.provisioning_uri
|
96
|
+
assert_match %r{^otpauth://hotp/\?secret=\w{32}&counter=0$}, @member.provisioning_uri
|
97
|
+
end
|
98
|
+
|
99
|
+
def test_provisioning_uri_with_options
|
100
|
+
assert_match %r{^otpauth://totp/Example\:roberto@heapsource\.com\?secret=\w{32}&issuer=Example$}, @user.provisioning_uri(nil, issuer: "Example")
|
101
|
+
assert_match %r{^otpauth://totp/Example\:roberto@heapsource\.com\?secret=\w{32}&issuer=Example$}, @visitor.provisioning_uri(nil, issuer: "Example")
|
102
|
+
assert_match %r{^otpauth://totp/Example\:roberto\?secret=\w{32}&issuer=Example$}, @user.provisioning_uri("roberto", issuer: "Example")
|
103
|
+
assert_match %r{^otpauth://totp/Example\:roberto\?secret=\w{32}&issuer=Example$}, @visitor.provisioning_uri("roberto", issuer: "Example")
|
104
|
+
end
|
105
|
+
|
106
|
+
def test_regenerate_otp
|
107
|
+
secret = @user.otp_column
|
108
|
+
@user.otp_regenerate_secret
|
109
|
+
assert secret != @user.otp_column
|
110
|
+
end
|
111
|
+
|
112
|
+
def test_hide_secret_key_in_serialize
|
113
|
+
refute_match(/otp_secret_key/, @user.to_json)
|
114
|
+
refute_match(/otp_secret_key/, @user.to_xml)
|
115
|
+
end
|
116
|
+
|
117
|
+
def test_otp_random_secret
|
118
|
+
assert_match /^.{32}$/, @user.class.otp_random_secret
|
42
119
|
end
|
43
120
|
end
|
data/test/schema.rb
ADDED
data/test/test_helper.rb
CHANGED
@@ -6,7 +6,16 @@ $LOAD_PATH.unshift libdir unless $LOAD_PATH.include?(libdir)
|
|
6
6
|
|
7
7
|
require "rubygems"
|
8
8
|
require "active_model_otp"
|
9
|
-
require "minitest/unit"
|
10
9
|
require "minitest/autorun"
|
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,100 +1,117 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: active_model_otp
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
5
|
-
prerelease:
|
4
|
+
version: 2.1.0
|
6
5
|
platform: ruby
|
7
6
|
authors:
|
8
7
|
- Guillermo Iguaran
|
9
8
|
- Roberto Miranda
|
10
9
|
- Heapsource
|
11
|
-
autorequire:
|
10
|
+
autorequire:
|
12
11
|
bindir: bin
|
13
12
|
cert_chain: []
|
14
|
-
date:
|
13
|
+
date: 2020-12-15 00:00:00.000000000 Z
|
15
14
|
dependencies:
|
16
15
|
- !ruby/object:Gem::Dependency
|
17
16
|
name: activemodel
|
18
17
|
requirement: !ruby/object:Gem::Requirement
|
19
|
-
none: false
|
20
18
|
requirements:
|
21
|
-
- -
|
19
|
+
- - ">="
|
22
20
|
- !ruby/object:Gem::Version
|
23
21
|
version: '0'
|
24
22
|
type: :runtime
|
25
23
|
prerelease: false
|
26
24
|
version_requirements: !ruby/object:Gem::Requirement
|
27
|
-
none: false
|
28
25
|
requirements:
|
29
|
-
- -
|
26
|
+
- - ">="
|
30
27
|
- !ruby/object:Gem::Version
|
31
28
|
version: '0'
|
32
29
|
- !ruby/object:Gem::Dependency
|
33
30
|
name: rotp
|
34
31
|
requirement: !ruby/object:Gem::Requirement
|
35
|
-
none: false
|
36
32
|
requirements:
|
37
|
-
- -
|
33
|
+
- - "~>"
|
38
34
|
- !ruby/object:Gem::Version
|
39
|
-
version:
|
35
|
+
version: 5.0.0
|
40
36
|
type: :runtime
|
41
37
|
prerelease: false
|
42
38
|
version_requirements: !ruby/object:Gem::Requirement
|
43
|
-
none: false
|
44
39
|
requirements:
|
45
|
-
- -
|
40
|
+
- - "~>"
|
46
41
|
- !ruby/object:Gem::Version
|
47
|
-
version:
|
42
|
+
version: 5.0.0
|
48
43
|
- !ruby/object:Gem::Dependency
|
49
|
-
name:
|
44
|
+
name: activerecord
|
50
45
|
requirement: !ruby/object:Gem::Requirement
|
51
|
-
none: false
|
52
46
|
requirements:
|
53
|
-
- -
|
47
|
+
- - ">="
|
54
48
|
- !ruby/object:Gem::Version
|
55
|
-
version: '
|
49
|
+
version: '0'
|
56
50
|
type: :development
|
57
51
|
prerelease: false
|
58
52
|
version_requirements: !ruby/object:Gem::Requirement
|
59
|
-
none: false
|
60
53
|
requirements:
|
61
|
-
- -
|
54
|
+
- - ">="
|
62
55
|
- !ruby/object:Gem::Version
|
63
|
-
version: '
|
56
|
+
version: '0'
|
64
57
|
- !ruby/object:Gem::Dependency
|
65
58
|
name: rake
|
66
59
|
requirement: !ruby/object:Gem::Requirement
|
67
|
-
none: false
|
68
60
|
requirements:
|
69
|
-
- -
|
61
|
+
- - ">="
|
70
62
|
- !ruby/object:Gem::Version
|
71
63
|
version: '0'
|
72
64
|
type: :development
|
73
65
|
prerelease: false
|
74
66
|
version_requirements: !ruby/object:Gem::Requirement
|
75
|
-
none: false
|
76
67
|
requirements:
|
77
|
-
- -
|
68
|
+
- - ">="
|
78
69
|
- !ruby/object:Gem::Version
|
79
70
|
version: '0'
|
80
71
|
- !ruby/object:Gem::Dependency
|
81
72
|
name: minitest
|
82
73
|
requirement: !ruby/object:Gem::Requirement
|
83
|
-
none: false
|
84
74
|
requirements:
|
85
|
-
- -
|
75
|
+
- - "~>"
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: 5.4.2
|
78
|
+
type: :development
|
79
|
+
prerelease: false
|
80
|
+
version_requirements: !ruby/object:Gem::Requirement
|
81
|
+
requirements:
|
82
|
+
- - "~>"
|
83
|
+
- !ruby/object:Gem::Version
|
84
|
+
version: 5.4.2
|
85
|
+
- !ruby/object:Gem::Dependency
|
86
|
+
name: appraisal
|
87
|
+
requirement: !ruby/object:Gem::Requirement
|
88
|
+
requirements:
|
89
|
+
- - ">="
|
86
90
|
- !ruby/object:Gem::Version
|
87
91
|
version: '0'
|
88
92
|
type: :development
|
89
93
|
prerelease: false
|
90
94
|
version_requirements: !ruby/object:Gem::Requirement
|
91
|
-
none: false
|
92
95
|
requirements:
|
93
|
-
- -
|
96
|
+
- - ">="
|
94
97
|
- !ruby/object:Gem::Version
|
95
98
|
version: '0'
|
96
|
-
|
97
|
-
|
99
|
+
- !ruby/object:Gem::Dependency
|
100
|
+
name: sqlite3
|
101
|
+
requirement: !ruby/object:Gem::Requirement
|
102
|
+
requirements:
|
103
|
+
- - "~>"
|
104
|
+
- !ruby/object:Gem::Version
|
105
|
+
version: 1.3.6
|
106
|
+
type: :development
|
107
|
+
prerelease: false
|
108
|
+
version_requirements: !ruby/object:Gem::Requirement
|
109
|
+
requirements:
|
110
|
+
- - "~>"
|
111
|
+
- !ruby/object:Gem::Version
|
112
|
+
version: 1.3.6
|
113
|
+
description: Adds methods to set and authenticate against one time passwords 2FA(Two
|
114
|
+
factor Authentication). Inspired in AM::SecurePassword"
|
98
115
|
email:
|
99
116
|
- guilleiguaran@gmail.com
|
100
117
|
- rjmaltamar@gmail.com
|
@@ -103,47 +120,60 @@ executables: []
|
|
103
120
|
extensions: []
|
104
121
|
extra_rdoc_files: []
|
105
122
|
files:
|
106
|
-
- .gitignore
|
107
|
-
- .travis.yml
|
123
|
+
- ".gitignore"
|
124
|
+
- ".travis.yml"
|
125
|
+
- Appraisals
|
126
|
+
- CHANGELOG.md
|
108
127
|
- Gemfile
|
109
128
|
- LICENSE.txt
|
110
129
|
- README.md
|
111
130
|
- Rakefile
|
112
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
|
113
137
|
- lib/active_model/one_time_password.rb
|
114
138
|
- lib/active_model/otp/version.rb
|
115
139
|
- lib/active_model_otp.rb
|
140
|
+
- test/models/activerecord_user.rb
|
141
|
+
- test/models/member.rb
|
142
|
+
- test/models/opt_in_two_factor.rb
|
116
143
|
- test/models/user.rb
|
117
144
|
- test/models/visitor.rb
|
118
145
|
- test/one_time_password_test.rb
|
146
|
+
- test/schema.rb
|
119
147
|
- test/test_helper.rb
|
120
148
|
homepage: ''
|
121
149
|
licenses:
|
122
150
|
- MIT
|
123
|
-
|
151
|
+
metadata: {}
|
152
|
+
post_install_message:
|
124
153
|
rdoc_options: []
|
125
154
|
require_paths:
|
126
155
|
- lib
|
127
156
|
required_ruby_version: !ruby/object:Gem::Requirement
|
128
|
-
none: false
|
129
157
|
requirements:
|
130
|
-
- -
|
158
|
+
- - ">="
|
131
159
|
- !ruby/object:Gem::Version
|
132
|
-
version: '
|
160
|
+
version: '2.3'
|
133
161
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
134
|
-
none: false
|
135
162
|
requirements:
|
136
|
-
- -
|
163
|
+
- - ">="
|
137
164
|
- !ruby/object:Gem::Version
|
138
165
|
version: '0'
|
139
166
|
requirements: []
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
specification_version: 3
|
167
|
+
rubygems_version: 3.0.3
|
168
|
+
signing_key:
|
169
|
+
specification_version: 4
|
144
170
|
summary: Adds methods to set and authenticate against one time passwords.
|
145
171
|
test_files:
|
172
|
+
- test/models/activerecord_user.rb
|
173
|
+
- test/models/member.rb
|
174
|
+
- test/models/opt_in_two_factor.rb
|
146
175
|
- test/models/user.rb
|
147
176
|
- test/models/visitor.rb
|
148
177
|
- test/one_time_password_test.rb
|
178
|
+
- test/schema.rb
|
149
179
|
- test/test_helper.rb
|