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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 973b2897bf7c434844a9ec652c599705fbfe6def2da99b20302059047ba960d1
4
- data.tar.gz: 4ac0735f61e0d74109ec39d4a6b7504028a76d2ab74f31bde8e90dbd9625a61a
3
+ metadata.gz: 3dc6bbf3b7c11ec96a00e223a7c894cf0f4aee17edd3173e800ba383013c0a5f
4
+ data.tar.gz: 739a0af12431fae65602b63fe9d69c3344f7add487cf3eaeaa199c75cfb0fdd5
5
5
  SHA512:
6
- metadata.gz: 53612a20dc401b03c051c48450e7161388fed1ab5d2c7d43ebcf12dbec82ca8f12e05d033237f505257d79c4e3ba9861fe5bdae943280e0b625ebb9a0bb95e2d
7
- data.tar.gz: 2cbb80548cccb92ff3ed398671c65aa600adc32de84a95b91287de67a4a7fbac45cf97d2d1d311bc1fc141cdb630a1a4e803a3785e79449ec00fea8ce3db5f2c
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) 5.0 or higher
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.
@@ -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", "~> 5.0.0"
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", "~> 1.3.6"
34
+ spec.add_development_dependency "sqlite3"
35
35
  end
36
36
  end
@@ -3,5 +3,6 @@
3
3
  source "https://rubygems.org"
4
4
 
5
5
  gem "activemodel", "~> 4.2"
6
+ gem "sqlite3", "~> 1.3.6"
6
7
 
7
8
  gemspec path: "../"
@@ -4,5 +4,6 @@ source "https://rubygems.org"
4
4
 
5
5
  gem "activemodel", "~> 5.0"
6
6
  gem "activemodel-serializers-xml"
7
+ gem "sqlite3", "~> 1.3.6"
7
8
 
8
9
  gemspec path: "../"
@@ -4,5 +4,6 @@ source "https://rubygems.org"
4
4
 
5
5
  gem "activemodel", "~> 5.1"
6
6
  gem "activemodel-serializers-xml"
7
+ gem "sqlite3", "~> 1.3.6"
7
8
 
8
9
  gemspec path: "../"
@@ -4,5 +4,6 @@ source "https://rubygems.org"
4
4
 
5
5
  gem "activemodel", "~> 5.2"
6
6
  gem "activemodel-serializers-xml"
7
+ gem "sqlite3", "~> 1.3.6"
7
8
 
8
9
  gemspec path: "../"
@@ -0,0 +1,10 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activerecord", "~> 6.1"
6
+ gem "activemodel", "~> 6.1"
7
+ gem "activemodel-serializers-xml"
8
+ gem "sqlite3", "~> 1.4"
9
+
10
+ gemspec path: "../"
@@ -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
- class_attribute :otp_digits, :otp_counter_based
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
@@ -1,5 +1,5 @@
1
1
  module ActiveModel
2
2
  module Otp
3
- VERSION = "2.1.0"
3
+ VERSION = "2.1.1".freeze
4
4
  end
5
5
  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::Unit::TestCase
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@heapsource\.com\?secret=\w{32}$}, @user.provisioning_uri
95
- assert_match %r{^otpauth://totp/roberto@heapsource\.com\?secret=\w{32}$}, @visitor.provisioning_uri
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@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")
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 /^.{32}$/, @user.class.otp_random_secret
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.0
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: 2020-12-15 00:00:00.000000000 Z
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: 5.0.0
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: 5.0.0
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: 1.3.6
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: 1.3.6
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