active_model_otp 2.1.0 → 2.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.travis.yml +17 -0
- data/Appraisals +11 -0
- data/README.md +46 -1
- data/active_model_otp.gemspec +3 -3
- data/gemfiles/rails_4.2.gemfile +1 -0
- data/gemfiles/rails_5.0.gemfile +1 -0
- data/gemfiles/rails_5.1.gemfile +1 -0
- data/gemfiles/rails_5.2.gemfile +1 -0
- data/gemfiles/rails_6.1.gemfile +10 -0
- data/lib/active_model/one_time_password.rb +45 -3
- data/lib/active_model/otp/version.rb +1 -1
- data/test/models/user.rb +2 -3
- data/test/one_time_password_test.rb +21 -7
- metadata +9 -8
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3dc6bbf3b7c11ec96a00e223a7c894cf0f4aee17edd3173e800ba383013c0a5f
|
4
|
+
data.tar.gz: 739a0af12431fae65602b63fe9d69c3344f7add487cf3eaeaa199c75cfb0fdd5
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 27e8578a087bd151ad1930fe91ab596a0459c754e788623ffe95fa0779c3f9d7e431fde02ee16448937997d6430d931299a2510bfdd6068212d6cc62fe33d66b
|
7
|
+
data.tar.gz: b6a32d54be8b38502e197ad7478cfa7fc2932fc8c0408bc158230975b8f9e753c95096ba75909a07a5573c8c68c1f65315002261b3813f8b1806050b28bf6ee8
|
data/.travis.yml
CHANGED
@@ -3,6 +3,8 @@ rvm:
|
|
3
3
|
- 2.4
|
4
4
|
- 2.5
|
5
5
|
- 2.6
|
6
|
+
- 2.7
|
7
|
+
- 3.0
|
6
8
|
- ruby-head
|
7
9
|
gemfile:
|
8
10
|
- gemfiles/rails_4.2.gemfile
|
@@ -10,12 +12,27 @@ gemfile:
|
|
10
12
|
- gemfiles/rails_5.1.gemfile
|
11
13
|
- gemfiles/rails_5.2.gemfile
|
12
14
|
- gemfiles/rails_6.0.gemfile
|
15
|
+
- gemfiles/rails_6.1.gemfile
|
13
16
|
matrix:
|
14
17
|
exclude:
|
15
18
|
- rvm: 2.3
|
16
19
|
gemfile: gemfiles/rails_6.0.gemfile
|
20
|
+
- rvm: 2.3
|
21
|
+
gemfile: gemfiles/rails_6.1.gemfile
|
17
22
|
- rvm: 2.4
|
18
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
|
19
36
|
fast_finish: true
|
20
37
|
allow_failures:
|
21
38
|
- rvm: ruby-head
|
data/Appraisals
CHANGED
@@ -1,20 +1,24 @@
|
|
1
1
|
appraise "rails-4.2" do
|
2
2
|
gem "activemodel", "~> 4.2"
|
3
|
+
gem "sqlite3", "~> 1.3.6"
|
3
4
|
end
|
4
5
|
|
5
6
|
appraise "rails-5.0" do
|
6
7
|
gem "activemodel", "~> 5.0"
|
7
8
|
gem "activemodel-serializers-xml"
|
9
|
+
gem "sqlite3", "~> 1.3.6"
|
8
10
|
end
|
9
11
|
|
10
12
|
appraise "rails-5.1" do
|
11
13
|
gem "activemodel", "~> 5.1"
|
12
14
|
gem "activemodel-serializers-xml"
|
15
|
+
gem "sqlite3", "~> 1.3.6"
|
13
16
|
end
|
14
17
|
|
15
18
|
appraise "rails-5.2" do
|
16
19
|
gem "activemodel", "~> 5.2"
|
17
20
|
gem "activemodel-serializers-xml"
|
21
|
+
gem "sqlite3", "~> 1.3.6"
|
18
22
|
end
|
19
23
|
|
20
24
|
appraise "rails-6.0" do
|
@@ -23,3 +27,10 @@ appraise "rails-6.0" do
|
|
23
27
|
gem "activemodel-serializers-xml"
|
24
28
|
gem "sqlite3", "~> 1.4"
|
25
29
|
end
|
30
|
+
|
31
|
+
appraise "rails-6.1" do
|
32
|
+
gem "activerecord", "~> 6.1"
|
33
|
+
gem "activemodel", "~> 6.1"
|
34
|
+
gem "activemodel-serializers-xml"
|
35
|
+
gem "sqlite3", "~> 1.4"
|
36
|
+
end
|
data/README.md
CHANGED
@@ -9,7 +9,7 @@
|
|
9
9
|
|
10
10
|
## Dependencies
|
11
11
|
|
12
|
-
* [ROTP](https://github.com/mdp/rotp)
|
12
|
+
* [ROTP](https://github.com/mdp/rotp) 6.2.0 or higher
|
13
13
|
* Ruby 2.3 or greater
|
14
14
|
|
15
15
|
## Installation
|
@@ -150,6 +150,51 @@ user.otp_code(auto_increment: true) # => '002811'
|
|
150
150
|
user.otp_code # => '002811'
|
151
151
|
```
|
152
152
|
|
153
|
+
## Backup codes
|
154
|
+
|
155
|
+
We're going to add a field to our ``User`` Model, so each user can have an otp backup codes. The next step is to run the migration generator in order to add the backup codes field.
|
156
|
+
|
157
|
+
```ruby
|
158
|
+
rails g migration AddOtpBackupCodesToUsers otp_backup_codes:text
|
159
|
+
=>
|
160
|
+
invoke active_record
|
161
|
+
create db/migrate/20210126030834_add_otp_backup_codes_to_users.rb
|
162
|
+
```
|
163
|
+
|
164
|
+
You can change backup codes column name by option `backup_codes_column_name`:
|
165
|
+
|
166
|
+
```ruby
|
167
|
+
class User < ApplicationRecord
|
168
|
+
has_one_time_password backup_codes_column_name: 'secret_codes'
|
169
|
+
end
|
170
|
+
```
|
171
|
+
|
172
|
+
Then use array type in schema or serialize attribute in model as Array (depending on used db type). Or even consider to use some libs like (lockbox)[https://github.com/ankane/lockbox] with type array.
|
173
|
+
|
174
|
+
After that user can use one of automatically generated backup codes for authentication using same method `authenticate_otp`.
|
175
|
+
|
176
|
+
By default it generates 12 backup codes. You can change it by option `backup_codes_count`:
|
177
|
+
|
178
|
+
```ruby
|
179
|
+
class User < ApplicationRecord
|
180
|
+
has_one_time_password backup_codes_count: 6
|
181
|
+
end
|
182
|
+
```
|
183
|
+
|
184
|
+
By default each backup code can be reused an infinite number of times. You can
|
185
|
+
change it with option `one_time_backup_codes`:
|
186
|
+
|
187
|
+
```ruby
|
188
|
+
class User < ApplicationRecord
|
189
|
+
has_one_time_password one_time_backup_codes: true
|
190
|
+
end
|
191
|
+
```
|
192
|
+
|
193
|
+
```ruby
|
194
|
+
user.authenticate_otp('186522') # => true
|
195
|
+
user.authenticate_otp('186522') # => false
|
196
|
+
```
|
197
|
+
|
153
198
|
## Google Authenticator Compatible
|
154
199
|
|
155
200
|
The library works with the Google Authenticator iPhone and Android app, and also includes the ability to generate provisioning URI's to use with the QR Code scanner built into the app.
|
data/active_model_otp.gemspec
CHANGED
@@ -17,11 +17,11 @@ Gem::Specification.new do |spec|
|
|
17
17
|
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
18
|
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
19
|
spec.require_paths = ["lib"]
|
20
|
-
|
20
|
+
|
21
21
|
spec.required_ruby_version = ">= 2.3"
|
22
22
|
|
23
23
|
spec.add_dependency "activemodel"
|
24
|
-
spec.add_dependency "rotp", "~>
|
24
|
+
spec.add_dependency "rotp", "~> 6.2.0"
|
25
25
|
|
26
26
|
spec.add_development_dependency "activerecord"
|
27
27
|
spec.add_development_dependency "rake"
|
@@ -31,6 +31,6 @@ Gem::Specification.new do |spec|
|
|
31
31
|
if RUBY_PLATFORM == "java"
|
32
32
|
spec.add_development_dependency "activerecord-jdbcsqlite3-adapter"
|
33
33
|
else
|
34
|
-
spec.add_development_dependency "sqlite3"
|
34
|
+
spec.add_development_dependency "sqlite3"
|
35
35
|
end
|
36
36
|
end
|
data/gemfiles/rails_4.2.gemfile
CHANGED
data/gemfiles/rails_5.0.gemfile
CHANGED
data/gemfiles/rails_5.1.gemfile
CHANGED
data/gemfiles/rails_5.2.gemfile
CHANGED
@@ -4,8 +4,10 @@ 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
|
-
|
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
|
9
11
|
|
10
12
|
self.otp_column_name = (options[:column_name] || "otp_secret_key").to_s
|
11
13
|
self.otp_digits = options[:length] || 6
|
@@ -13,11 +15,20 @@ module ActiveModel
|
|
13
15
|
self.otp_counter_based = (options[:counter_based] || false)
|
14
16
|
self.otp_counter_column_name = (options[:counter_column_name] || "otp_counter").to_s
|
15
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
|
+
|
16
26
|
include InstanceMethodsOnActivation
|
17
27
|
|
18
|
-
before_create(options.slice(:if, :unless)) do
|
28
|
+
before_create(**options.slice(:if, :unless)) do
|
19
29
|
self.otp_regenerate_secret if !otp_column
|
20
30
|
self.otp_regenerate_counter if otp_counter_based && !otp_counter
|
31
|
+
otp_regenerate_backup_codes if backup_codes_enabled?
|
21
32
|
end
|
22
33
|
|
23
34
|
if respond_to?(:attributes_protected_by_default)
|
@@ -44,6 +55,8 @@ module ActiveModel
|
|
44
55
|
end
|
45
56
|
|
46
57
|
def authenticate_otp(code, options = {})
|
58
|
+
return true if backup_codes_enabled? && authenticate_backup_code(code)
|
59
|
+
|
47
60
|
if otp_counter_based
|
48
61
|
hotp = ROTP::HOTP.new(otp_column, digits: otp_digits)
|
49
62
|
result = hotp.verify(code, otp_counter)
|
@@ -120,6 +133,35 @@ module ActiveModel
|
|
120
133
|
options[:except] << self.class.otp_column_name
|
121
134
|
super(options)
|
122
135
|
end
|
136
|
+
|
137
|
+
def otp_regenerate_backup_codes
|
138
|
+
otp = ROTP::OTP.new(otp_column)
|
139
|
+
backup_codes = Array.new(self.class.otp_backup_codes_count) do
|
140
|
+
otp.generate_otp((SecureRandom.random_number(9e5) + 1e5).to_i)
|
141
|
+
end
|
142
|
+
|
143
|
+
public_send("#{self.class.otp_backup_codes_column_name}=", backup_codes)
|
144
|
+
end
|
145
|
+
|
146
|
+
def backup_codes_enabled?
|
147
|
+
self.class.attribute_method?(self.class.otp_backup_codes_column_name)
|
148
|
+
end
|
149
|
+
|
150
|
+
private
|
151
|
+
|
152
|
+
def authenticate_backup_code(code)
|
153
|
+
backup_codes_column_name = self.class.otp_backup_codes_column_name
|
154
|
+
backup_codes = public_send(backup_codes_column_name)
|
155
|
+
return false unless backup_codes.include?(code)
|
156
|
+
|
157
|
+
if self.class.otp_one_time_backup_codes
|
158
|
+
backup_codes.delete(code)
|
159
|
+
public_send("#{backup_codes_column_name}=", backup_codes)
|
160
|
+
save if respond_to?(:changed?) && !new_record?
|
161
|
+
end
|
162
|
+
|
163
|
+
true
|
164
|
+
end
|
123
165
|
end
|
124
166
|
end
|
125
167
|
end
|
data/test/models/user.rb
CHANGED
@@ -1,14 +1,13 @@
|
|
1
1
|
class User
|
2
2
|
extend ActiveModel::Callbacks
|
3
3
|
include ActiveModel::Serializers::JSON
|
4
|
-
include ActiveModel::Serializers::Xml
|
5
4
|
include ActiveModel::Validations
|
6
5
|
include ActiveModel::OneTimePassword
|
7
6
|
|
8
7
|
define_model_callbacks :create
|
9
|
-
attr_accessor :otp_secret_key, :email
|
8
|
+
attr_accessor :otp_secret_key, :otp_backup_codes, :email
|
10
9
|
|
11
|
-
has_one_time_password
|
10
|
+
has_one_time_password one_time_backup_codes: true
|
12
11
|
def attributes
|
13
12
|
{ "otp_secret_key" => otp_secret_key, "email" => email }
|
14
13
|
end
|
@@ -1,6 +1,6 @@
|
|
1
1
|
require "test_helper"
|
2
2
|
|
3
|
-
class OtpTest < MiniTest::
|
3
|
+
class OtpTest < MiniTest::Test
|
4
4
|
def setup
|
5
5
|
@user = User.new
|
6
6
|
@user.email = 'roberto@heapsource.com'
|
@@ -69,6 +69,21 @@ class OtpTest < MiniTest::Unit::TestCase
|
|
69
69
|
assert @visitor.authenticate_otp(code, drift: 60)
|
70
70
|
end
|
71
71
|
|
72
|
+
def test_authenticate_with_backup_code
|
73
|
+
backup_code = @user.public_send(@user.otp_backup_codes_column_name).first
|
74
|
+
assert @user.authenticate_otp(backup_code)
|
75
|
+
|
76
|
+
backup_code = @user.public_send(@user.otp_backup_codes_column_name).last
|
77
|
+
@user.otp_regenerate_backup_codes
|
78
|
+
assert !@user.authenticate_otp(backup_code)
|
79
|
+
end
|
80
|
+
|
81
|
+
def test_authenticate_with_one_time_backup_code
|
82
|
+
backup_code = @user.public_send(@user.otp_backup_codes_column_name).first
|
83
|
+
assert @user.authenticate_otp(backup_code)
|
84
|
+
assert !@user.authenticate_otp(backup_code)
|
85
|
+
end
|
86
|
+
|
72
87
|
def test_otp_code
|
73
88
|
assert_match(/^\d{6}$/, @user.otp_code.to_s)
|
74
89
|
assert_match(/^\d{4}$/, @visitor.otp_code.to_s)
|
@@ -91,14 +106,14 @@ class OtpTest < MiniTest::Unit::TestCase
|
|
91
106
|
end
|
92
107
|
|
93
108
|
def test_provisioning_uri_with_email_field
|
94
|
-
assert_match %r{^otpauth://totp/roberto
|
95
|
-
assert_match %r{^otpauth://totp/roberto
|
109
|
+
assert_match %r{^otpauth://totp/roberto%40heapsource\.com\?secret=\w{32}$}, @user.provisioning_uri
|
110
|
+
assert_match %r{^otpauth://totp/roberto%40heapsource\.com\?secret=\w{32}$}, @visitor.provisioning_uri
|
96
111
|
assert_match %r{^otpauth://hotp/\?secret=\w{32}&counter=0$}, @member.provisioning_uri
|
97
112
|
end
|
98
113
|
|
99
114
|
def test_provisioning_uri_with_options
|
100
|
-
assert_match %r{^otpauth://totp/Example\:roberto
|
101
|
-
assert_match %r{^otpauth://totp/Example\:roberto
|
115
|
+
assert_match %r{^otpauth://totp/Example\:roberto%40heapsource\.com\?secret=\w{32}&issuer=Example$}, @user.provisioning_uri(nil, issuer: "Example")
|
116
|
+
assert_match %r{^otpauth://totp/Example\:roberto%40heapsource\.com\?secret=\w{32}&issuer=Example$}, @visitor.provisioning_uri(nil, issuer: "Example")
|
102
117
|
assert_match %r{^otpauth://totp/Example\:roberto\?secret=\w{32}&issuer=Example$}, @user.provisioning_uri("roberto", issuer: "Example")
|
103
118
|
assert_match %r{^otpauth://totp/Example\:roberto\?secret=\w{32}&issuer=Example$}, @visitor.provisioning_uri("roberto", issuer: "Example")
|
104
119
|
end
|
@@ -111,10 +126,9 @@ class OtpTest < MiniTest::Unit::TestCase
|
|
111
126
|
|
112
127
|
def test_hide_secret_key_in_serialize
|
113
128
|
refute_match(/otp_secret_key/, @user.to_json)
|
114
|
-
refute_match(/otp_secret_key/, @user.to_xml)
|
115
129
|
end
|
116
130
|
|
117
131
|
def test_otp_random_secret
|
118
|
-
assert_match
|
132
|
+
assert_match(/^.{32}$/, @user.class.otp_random_secret)
|
119
133
|
end
|
120
134
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: active_model_otp
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.1.
|
4
|
+
version: 2.1.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Guillermo Iguaran
|
@@ -10,7 +10,7 @@ authors:
|
|
10
10
|
autorequire:
|
11
11
|
bindir: bin
|
12
12
|
cert_chain: []
|
13
|
-
date:
|
13
|
+
date: 2021-03-07 00:00:00.000000000 Z
|
14
14
|
dependencies:
|
15
15
|
- !ruby/object:Gem::Dependency
|
16
16
|
name: activemodel
|
@@ -32,14 +32,14 @@ dependencies:
|
|
32
32
|
requirements:
|
33
33
|
- - "~>"
|
34
34
|
- !ruby/object:Gem::Version
|
35
|
-
version:
|
35
|
+
version: 6.2.0
|
36
36
|
type: :runtime
|
37
37
|
prerelease: false
|
38
38
|
version_requirements: !ruby/object:Gem::Requirement
|
39
39
|
requirements:
|
40
40
|
- - "~>"
|
41
41
|
- !ruby/object:Gem::Version
|
42
|
-
version:
|
42
|
+
version: 6.2.0
|
43
43
|
- !ruby/object:Gem::Dependency
|
44
44
|
name: activerecord
|
45
45
|
requirement: !ruby/object:Gem::Requirement
|
@@ -100,16 +100,16 @@ dependencies:
|
|
100
100
|
name: sqlite3
|
101
101
|
requirement: !ruby/object:Gem::Requirement
|
102
102
|
requirements:
|
103
|
-
- - "
|
103
|
+
- - ">="
|
104
104
|
- !ruby/object:Gem::Version
|
105
|
-
version:
|
105
|
+
version: '0'
|
106
106
|
type: :development
|
107
107
|
prerelease: false
|
108
108
|
version_requirements: !ruby/object:Gem::Requirement
|
109
109
|
requirements:
|
110
|
-
- - "
|
110
|
+
- - ">="
|
111
111
|
- !ruby/object:Gem::Version
|
112
|
-
version:
|
112
|
+
version: '0'
|
113
113
|
description: Adds methods to set and authenticate against one time passwords 2FA(Two
|
114
114
|
factor Authentication). Inspired in AM::SecurePassword"
|
115
115
|
email:
|
@@ -134,6 +134,7 @@ files:
|
|
134
134
|
- gemfiles/rails_5.1.gemfile
|
135
135
|
- gemfiles/rails_5.2.gemfile
|
136
136
|
- gemfiles/rails_6.0.gemfile
|
137
|
+
- gemfiles/rails_6.1.gemfile
|
137
138
|
- lib/active_model/one_time_password.rb
|
138
139
|
- lib/active_model/otp/version.rb
|
139
140
|
- lib/active_model_otp.rb
|