active_model_otp 1.0.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,15 +1,7 @@
1
1
  ---
2
- !binary "U0hBMQ==":
3
- metadata.gz: !binary |-
4
- ZjdjOTMxYzkxNjI1MGM0ODdiZmNkZmFiYTZiMjBlZjc4ZTQzMjg2Mw==
5
- data.tar.gz: !binary |-
6
- ZTQ4OGJlNTQzOTViZWEzNDU1MWU1YmQ3Y2Q5Njc3ZDQ0ZjdmMDVkYQ==
2
+ SHA256:
3
+ metadata.gz: 3dc6bbf3b7c11ec96a00e223a7c894cf0f4aee17edd3173e800ba383013c0a5f
4
+ data.tar.gz: 739a0af12431fae65602b63fe9d69c3344f7add487cf3eaeaa199c75cfb0fdd5
7
5
  SHA512:
8
- metadata.gz: !binary |-
9
- YWIyZDViZGE0YWRhYjc2M2YyZjRkYWVhMDI3NjA4YjgxMzRhY2E1MjNkOWM4
10
- MmE5YjZlOTlhYmVkMDhkZjNjZjMzOTYzNzkzNTQ0ODZjMGEzOTRjZTc1OGNj
11
- NDYwNDRiNmEzNmNjZWJhYTQxNzdhNzE2MzBjYzVhNjFhMzAzMWQ=
12
- data.tar.gz: !binary |-
13
- YjdkNDlkYmRjNWM1MTRlZDQzZTMwNzFkZGE4OGMwMzU5NmQ1YWExMDY4YTA2
14
- NTMwOTgwYWE5NjdkM2M1MzdiMDc3MGZhMWM0YWUzMDA2NDFlNDFmNWM3ZTlk
15
- Y2ViODliODQ4YzQxYmY1Y2UyNWNiYjIzY2QwMDQzZDllNWFhYjQ=
6
+ metadata.gz: 27e8578a087bd151ad1930fe91ab596a0459c754e788623ffe95fa0779c3f9d7e431fde02ee16448937997d6430d931299a2510bfdd6068212d6cc62fe33d66b
7
+ data.tar.gz: b6a32d54be8b38502e197ad7478cfa7fc2932fc8c0408bc158230975b8f9e753c95096ba75909a07a5573c8c68c1f65315002261b3813f8b1806050b28bf6ee8
data/.gitignore CHANGED
@@ -16,3 +16,4 @@ test/tmp
16
16
  test/version_tmp
17
17
  tmp
18
18
  .ruby-version
19
+ gemfiles/*.lock
data/.travis.yml CHANGED
@@ -1,11 +1,43 @@
1
1
  rvm:
2
- - 1.9.3
3
- - 2.0.0
2
+ - 2.3
3
+ - 2.4
4
+ - 2.5
5
+ - 2.6
6
+ - 2.7
7
+ - 3.0
8
+ - ruby-head
9
+ gemfile:
10
+ - gemfiles/rails_4.2.gemfile
11
+ - gemfiles/rails_5.0.gemfile
12
+ - gemfiles/rails_5.1.gemfile
13
+ - gemfiles/rails_5.2.gemfile
14
+ - gemfiles/rails_6.0.gemfile
15
+ - gemfiles/rails_6.1.gemfile
4
16
  matrix:
5
- include:
6
- - rvm: jruby
7
- env: JRUBY_OPTS="--1.9 --server -Xcext.enabled=true"
8
- - rvm: jruby-head
9
- env: JRUBY_OPTS="--1.9 --server -Xcext.enabled=true"
17
+ exclude:
18
+ - rvm: 2.3
19
+ gemfile: gemfiles/rails_6.0.gemfile
20
+ - rvm: 2.3
21
+ gemfile: gemfiles/rails_6.1.gemfile
22
+ - rvm: 2.4
23
+ gemfile: gemfiles/rails_6.0.gemfile
24
+ - rvm: 2.4
25
+ gemfile: gemfiles/rails_6.1.gemfile
26
+ - rvm: 2.7
27
+ gemfile: gemfiles/rails_4.2.gemfile
28
+ - rvm: 3.0
29
+ gemfile: gemfiles/rails_4.2.gemfile
30
+ - rvm: 3.0
31
+ gemfile: gemfiles/rails_5.0.gemfile
32
+ - rvm: 3.0
33
+ gemfile: gemfiles/rails_5.1.gemfile
34
+ - rvm: 3.0
35
+ gemfile: gemfiles/rails_5.2.gemfile
36
+ fast_finish: true
37
+ allow_failures:
38
+ - rvm: ruby-head
39
+ # include:
40
+ # - rvm: jruby
41
+ # env: JRUBY_OPTS="--1.9 --server -Xcext.enabled=true"
10
42
  notifications:
11
43
  email: false
data/Appraisals ADDED
@@ -0,0 +1,36 @@
1
+ appraise "rails-4.2" do
2
+ gem "activemodel", "~> 4.2"
3
+ gem "sqlite3", "~> 1.3.6"
4
+ end
5
+
6
+ appraise "rails-5.0" do
7
+ gem "activemodel", "~> 5.0"
8
+ gem "activemodel-serializers-xml"
9
+ gem "sqlite3", "~> 1.3.6"
10
+ end
11
+
12
+ appraise "rails-5.1" do
13
+ gem "activemodel", "~> 5.1"
14
+ gem "activemodel-serializers-xml"
15
+ gem "sqlite3", "~> 1.3.6"
16
+ end
17
+
18
+ appraise "rails-5.2" do
19
+ gem "activemodel", "~> 5.2"
20
+ gem "activemodel-serializers-xml"
21
+ gem "sqlite3", "~> 1.3.6"
22
+ end
23
+
24
+ appraise "rails-6.0" do
25
+ gem "activerecord", "~> 6.0"
26
+ gem "activemodel", "~> 6.0"
27
+ gem "activemodel-serializers-xml"
28
+ gem "sqlite3", "~> 1.4"
29
+ end
30
+
31
+ appraise "rails-6.1" do
32
+ gem "activerecord", "~> 6.1"
33
+ gem "activemodel", "~> 6.1"
34
+ gem "activemodel-serializers-xml"
35
+ gem "sqlite3", "~> 1.4"
36
+ end
data/CHANGELOG.md CHANGED
@@ -1,9 +1 @@
1
- #v1.0.0
2
- - Avoid overriding predefined otp_column value when initializing resource (Ilan Stern) https://github.com/heapsource/active_model_otp/pull/10
3
- - Pad OTP codes with less than 6 digits (Johan Brissmyr) https://github.com/heapsource/active_model_otp/pull/7
4
- - Get rid of deprecation warnings in Rails 4.1 (Nick DeMonner)
5
-
6
- #v0.1.0
7
- - OTP codes can be in 5 or 6 digits (André Luis Leal Cardoso Junior)
8
- - Require 'cgi', rotp needs it for encoding parameters (André Luis Leal Cardoso Junior)
9
- - 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
@@ -1,4 +1,4 @@
1
- Copyright (c) 2013 Guillermo Iguaran, Roberto Miranda, Firebase.co
1
+ Copyright (c) 2013-2019 Roberto Miranda, Guillermo Iguaran and contributors.
2
2
 
3
3
  MIT License
4
4
 
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, let's see what's required to get AMo::Otp working in our Application, using Rails 4.0 (AMo::Otp is also compatible with Rails 3.x versions) we're going to use an User model and some authentication to it. Inspired in AM::SecurePassword
8
+ **ActiveModel::Otp** makes adding **Two Factor Authentication** (TFA) to a model simple. Let's see what's required to get AMo::Otp working in our Application, using Rails 5.0 (AMo::Otp is also compatible with Rails 4.x versions). We're going to use a User model and try to add options provided by **ActiveModel::Otp**. Inspired by AM::SecurePassword
9
+
10
+ ## Dependencies
11
+
12
+ * [ROTP](https://github.com/mdp/rotp) 6.2.0 or higher
13
+ * Ruby 2.3 or greater
6
14
 
7
15
  ## Installation
8
16
 
@@ -14,7 +22,7 @@ And then execute:
14
22
 
15
23
  $ bundle
16
24
 
17
- Or install it yourself as:
25
+ Or install it yourself as follows:
18
26
 
19
27
  $ gem install active_model_otp
20
28
 
@@ -29,25 +37,32 @@ rails g migration AddOtpSecretKeyToUsers otp_secret_key:string
29
37
  create db/migrate/20130707010931_add_otp_secret_key_to_users.rb
30
38
  ```
31
39
 
32
- We’ll then need to run rake db:migrate to update the users table in the database. The next step is to update the model code. We need to use has_one_time_password to tell it will be use TFA.
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 < ActiveRecord::Base
43
+ class User < ApplicationRecord
36
44
  has_one_time_password
37
45
  end
38
46
  ```
39
47
 
40
48
  Note: If you're adding this to an existing user model you'll need to generate *otp_secret_key* with a migration like:
41
49
  ```ruby
42
- User.all.each { |user| user.update_attribute(:otp_secret_key, ROTP::Base32.random_base32) }
50
+ User.find_each { |user| user.update_attribute(:otp_secret_key, User.otp_random_secret) }
43
51
  ```
44
52
 
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.
54
+
55
+ ```ruby
56
+ class User < ApplicationRecord
57
+ has_one_time_password column_name: :my_otp_secret_column, length: 4
58
+ end
59
+ ```
45
60
 
46
- ##Usage
61
+ ## Usage
47
62
 
48
- The has_one_time_password sentence provides to the model some useful methods in order to implement our TFA system. AMo:Otp generates one time passwords according to [RFC 4226](http://tools.ietf.org/html/rfc4226) and the [HOTP RFC](http://tools.ietf.org/html/draft-mraihi-totp-timebased-00). This is compatible with Google Authenticator apps available for Android and iPhone, and now in use on GMail.
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.
49
64
 
50
- The otp_secret_key is saved automatically when a object is created,
65
+ The otp_secret_key is saved automatically when an object is created,
51
66
 
52
67
  ```ruby
53
68
  user = User.create(email: "hello@heapsource.com")
@@ -55,9 +70,9 @@ user.otp_secret_key
55
70
  => "jt3gdd2qm6su5iqh"
56
71
  ```
57
72
 
58
- **Note:** You can fork the applications for [iPhone](https://github.com/heapsource/google-authenticator) & [Android](https://github.com/heapsource/google-authenticator.android) and customize it
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
59
74
 
60
- ### Getting current code (ex. to send via SMS)
75
+ ### Getting current code (e.g. to send via SMS)
61
76
  ```ruby
62
77
  user.otp_code # => '186522'
63
78
  sleep 30
@@ -65,9 +80,6 @@ user.otp_code # => '850738'
65
80
 
66
81
  # Override current time
67
82
  user.otp_code(time: Time.now + 3600) # => '317438'
68
-
69
- # Don't zero-pad to six digits
70
- user.otp_code(padding: false) # => '438'
71
83
  ```
72
84
 
73
85
  ### Authenticating using a code
@@ -86,19 +98,120 @@ sleep 30 # lets wait again
86
98
  user.authenticate_otp('186522', drift: 60) # => true
87
99
  ```
88
100
 
101
+ ## Counter based OTP
102
+
103
+ An additonal counter field is required in our ``User`` Model
104
+
105
+ ```ruby
106
+ rails g migration AddCounterForOtpToUsers otp_counter:integer
107
+ =>
108
+ invoke active_record
109
+ create db/migrate/20130707010931_add_counter_for_otp_to_users.rb
110
+ ```
111
+
112
+ Set default value for otp_counter to 0.
113
+ ```ruby
114
+ change_column :users, :otp_counter, :integer, default: 0
115
+ ```
116
+
117
+ In addition set the counter flag option to true
118
+
119
+ ```ruby
120
+ class User < ApplicationRecord
121
+ has_one_time_password counter_based: true
122
+ end
123
+ ```
124
+
125
+ And for a custom counter column
126
+
127
+ ```ruby
128
+ class User < ApplicationRecord
129
+ has_one_time_password counter_based: true, counter_column_name: :my_otp_secret_counter_column
130
+ end
131
+ ```
132
+
133
+ Authentication is done the same. You can manually adjust the counter for your usage or set auto_increment on success to true.
134
+
135
+ ```ruby
136
+ user.authenticate_otp('186522') # => true
137
+ user.authenticate_otp('186522', auto_increment: true) # => true
138
+ user.authenticate_otp('186522') # => false
139
+ user.otp_counter -= 1
140
+ user.authenticate_otp('186522') # => true
141
+ ```
142
+
143
+ When retrieving an ```otp_code``` you can also pass the ```auto_increment``` option.
144
+
145
+ ```ruby
146
+ user.otp_code # => '186522'
147
+ user.otp_code # => '186522'
148
+ user.otp_code(auto_increment: true) # => '768273'
149
+ user.otp_code(auto_increment: true) # => '002811'
150
+ user.otp_code # => '002811'
151
+ ```
152
+
153
+ ## Backup codes
154
+
155
+ We're going to add a field to our ``User`` Model, so each user can have an otp backup codes. The next step is to run the migration generator in order to add the backup codes field.
156
+
157
+ ```ruby
158
+ rails g migration AddOtpBackupCodesToUsers otp_backup_codes:text
159
+ =>
160
+ invoke active_record
161
+ create db/migrate/20210126030834_add_otp_backup_codes_to_users.rb
162
+ ```
163
+
164
+ You can change backup codes column name by option `backup_codes_column_name`:
165
+
166
+ ```ruby
167
+ class User < ApplicationRecord
168
+ has_one_time_password backup_codes_column_name: 'secret_codes'
169
+ end
170
+ ```
171
+
172
+ Then use array type in schema or serialize attribute in model as Array (depending on used db type). Or even consider to use some libs like (lockbox)[https://github.com/ankane/lockbox] with type array.
173
+
174
+ After that user can use one of automatically generated backup codes for authentication using same method `authenticate_otp`.
175
+
176
+ By default it generates 12 backup codes. You can change it by option `backup_codes_count`:
177
+
178
+ ```ruby
179
+ class User < ApplicationRecord
180
+ has_one_time_password backup_codes_count: 6
181
+ end
182
+ ```
183
+
184
+ By default each backup code can be reused an infinite number of times. You can
185
+ change it with option `one_time_backup_codes`:
186
+
187
+ ```ruby
188
+ class User < ApplicationRecord
189
+ has_one_time_password one_time_backup_codes: true
190
+ end
191
+ ```
192
+
193
+ ```ruby
194
+ user.authenticate_otp('186522') # => true
195
+ user.authenticate_otp('186522') # => false
196
+ ```
197
+
89
198
  ## Google Authenticator Compatible
90
199
 
91
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.
92
201
 
93
202
  ```ruby
94
- # Use you user's emails for generate the provisioning_url
203
+ # Use your user's email address to generate the provisioning_url
95
204
  user.provisioning_uri # => 'otpauth://totp/hello@heapsource.com?secret=2z6hxkdwi3uvrnpn'
96
205
 
97
- # Use a custom fied for generate the provisioning_url
206
+ # Use a custom field to generate the provisioning_url
98
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'
99
212
  ```
100
213
 
101
- This can then be rendered as a QR Code which can then be scanned and added to the users list of OTP credentials.
214
+ This can then be rendered as a QR Code which can be scanned and added to the users list of OTP credentials.
102
215
 
103
216
  ### Working example
104
217
 
@@ -110,6 +223,7 @@ Now run the following and compare the output
110
223
 
111
224
  ```ruby
112
225
  require "active_model_otp"
226
+
113
227
  class User
114
228
  extend ActiveModel::Callbacks
115
229
  include ActiveModel::Validations
@@ -120,6 +234,7 @@ class User
120
234
 
121
235
  has_one_time_password
122
236
  end
237
+
123
238
  user = User.new
124
239
  user.email = 'roberto@heapsource.com'
125
240
  user.otp_secret_key = "2z6hxkdwi3uvrnpn"
@@ -129,11 +244,11 @@ puts "Current code #{user.otp_code}"
129
244
  **Note:** otp_secret_key must be generated using RFC 3548 base32 key strings (for compatilibity with google authenticator)
130
245
 
131
246
  ### Useful Examples
132
-
247
+ - [Drifting Ruby Tutorial](https://www.driftingruby.com/episodes/two-factor-authentication)
133
248
  - [Generate QR code with rqrcode gem](https://github.com/heapsource/active_model_otp/wiki/Generate-QR-code-with-rqrcode-gem)
134
249
  - Generating QR Code with Google Charts API
135
- - [Sendind code via email with Twilio](https://github.com/heapsource/active_model_otp/wiki/Send-code-via-Twilio-SMS)
136
- - Using with Mongoid
250
+ - [Sending code via SMS with Twilio](https://github.com/heapsource/active_model_otp/wiki/Send-code-via-Twilio-SMS)
251
+ - [Using with Mongoid](https://github.com/heapsource/active_model_otp/wiki/Using-with-Mongoid)
137
252
 
138
253
  ## Contributing
139
254
 
@@ -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 "bundler", "~> 1.3"
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"
35
+ end
27
36
  end
@@ -0,0 +1,8 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activemodel", "~> 4.2"
6
+ gem "sqlite3", "~> 1.3.6"
7
+
8
+ gemspec path: "../"
@@ -0,0 +1,9 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activemodel", "~> 5.0"
6
+ gem "activemodel-serializers-xml"
7
+ gem "sqlite3", "~> 1.3.6"
8
+
9
+ gemspec path: "../"
@@ -0,0 +1,9 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activemodel", "~> 5.1"
6
+ gem "activemodel-serializers-xml"
7
+ gem "sqlite3", "~> 1.3.6"
8
+
9
+ gemspec path: "../"
@@ -0,0 +1,9 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activemodel", "~> 5.2"
6
+ gem "activemodel-serializers-xml"
7
+ gem "sqlite3", "~> 1.3.6"
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.0"
6
+ gem "activemodel", "~> 6.0"
7
+ gem "activemodel-serializers-xml"
8
+ gem "sqlite3", "~> 1.4"
9
+
10
+ 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,56 +4,164 @@ 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
+ :otp_backup_codes_column_name
9
+ class_attribute :otp_digits, :otp_counter_based,
10
+ :otp_backup_codes_count, :otp_one_time_backup_codes
7
11
 
8
- cattr_accessor :otp_column_name
9
12
  self.otp_column_name = (options[:column_name] || "otp_secret_key").to_s
13
+ self.otp_digits = options[:length] || 6
14
+
15
+ self.otp_counter_based = (options[:counter_based] || false)
16
+ self.otp_counter_column_name = (options[:counter_column_name] || "otp_counter").to_s
17
+
18
+ self.otp_backup_codes_column_name = (
19
+ options[:backup_codes_column_name] || 'otp_backup_codes'
20
+ ).to_s
21
+ self.otp_backup_codes_count = options[:backup_codes_count] || 12
22
+ self.otp_one_time_backup_codes = (
23
+ options[:one_time_backup_codes] || false
24
+ )
10
25
 
11
26
  include InstanceMethodsOnActivation
12
27
 
13
- before_create { self.otp_column ||= ROTP::Base32.random_base32 }
28
+ before_create(**options.slice(:if, :unless)) do
29
+ self.otp_regenerate_secret if !otp_column
30
+ self.otp_regenerate_counter if otp_counter_based && !otp_counter
31
+ otp_regenerate_backup_codes if backup_codes_enabled?
32
+ end
14
33
 
15
34
  if respond_to?(:attributes_protected_by_default)
16
35
  def self.attributes_protected_by_default #:nodoc:
17
- super + [self.otp_column_name]
36
+ super + [otp_column_name, otp_counter_column_name]
18
37
  end
19
38
  end
20
39
  end
40
+
41
+ # Defaults to 160 bit long secret
42
+ # (meaning a 32 character long base32 secret)
43
+ def otp_random_secret(length = 20)
44
+ ROTP::Base32.random(length)
45
+ end
21
46
  end
22
47
 
23
48
  module InstanceMethodsOnActivation
49
+ def otp_regenerate_secret
50
+ self.otp_column = self.class.otp_random_secret
51
+ end
52
+
53
+ def otp_regenerate_counter
54
+ self.otp_counter = 1
55
+ end
56
+
24
57
  def authenticate_otp(code, options = {})
25
- totp = ROTP::TOTP.new(self.otp_column)
26
- if drift = options[:drift]
27
- totp.verify_with_drift(code, drift)
58
+ return true if backup_codes_enabled? && authenticate_backup_code(code)
59
+
60
+ if otp_counter_based
61
+ hotp = ROTP::HOTP.new(otp_column, digits: otp_digits)
62
+ result = hotp.verify(code, otp_counter)
63
+ if result && options[:auto_increment]
64
+ self.otp_counter += 1
65
+ save if respond_to?(:changed?) && !new_record?
66
+ end
67
+ result
28
68
  else
29
- totp.verify(code)
69
+ totp = ROTP::TOTP.new(otp_column, digits: otp_digits)
70
+ if drift = options[:drift]
71
+ totp.verify(code, drift_behind: drift)
72
+ else
73
+ totp.verify(code)
74
+ end
30
75
  end
31
76
  end
32
77
 
33
78
  def otp_code(options = {})
34
- if options.is_a? Hash
35
- time = options.fetch(:time, Time.now)
36
- padding = options.fetch(:padding, true)
79
+ if otp_counter_based
80
+ if options[:auto_increment]
81
+ self.otp_counter += 1
82
+ save if respond_to?(:changed?) && !new_record?
83
+ end
84
+ ROTP::HOTP.new(otp_column, digits: otp_digits).at(self.otp_counter)
37
85
  else
38
- time = options
39
- padding = true
86
+ if options.is_a? Hash
87
+ time = options.fetch(:time, Time.now)
88
+ else
89
+ time = options
90
+ end
91
+ ROTP::TOTP.new(otp_column, digits: otp_digits).at(time)
40
92
  end
41
- ROTP::TOTP.new(self.otp_column).at(time, padding)
42
93
  end
43
94
 
44
- def provisioning_uri(account = nil)
95
+ def provisioning_uri(account = nil, options = {})
45
96
  account ||= self.email if self.respond_to?(:email)
46
- ROTP::TOTP.new(self.otp_column).provisioning_uri(account)
97
+ account ||= ""
98
+
99
+ if otp_counter_based
100
+ ROTP::HOTP.new(otp_column, options).provisioning_uri(account)
101
+ else
102
+ ROTP::TOTP.new(otp_column, options).provisioning_uri(account)
103
+ end
47
104
  end
48
105
 
49
106
  def otp_column
50
- self.send(self.class.otp_column_name)
107
+ self.public_send(self.class.otp_column_name)
51
108
  end
52
109
 
53
110
  def otp_column=(attr)
54
- self.send("#{self.class.otp_column_name}=", attr)
111
+ self.public_send("#{self.class.otp_column_name}=", attr)
112
+ end
113
+
114
+ def otp_counter
115
+ if self.class.otp_counter_column_name != "otp_counter"
116
+ self.public_send(self.class.otp_counter_column_name)
117
+ else
118
+ super
119
+ end
120
+ end
121
+
122
+ def otp_counter=(attr)
123
+ if self.class.otp_counter_column_name != "otp_counter"
124
+ self.public_send("#{self.class.otp_counter_column_name}=", attr)
125
+ else
126
+ super
127
+ end
128
+ end
129
+
130
+ def serializable_hash(options = nil)
131
+ options ||= {}
132
+ options[:except] = Array(options[:except])
133
+ options[:except] << self.class.otp_column_name
134
+ super(options)
55
135
  end
56
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
57
165
  end
58
166
  end
59
167
  end
@@ -1,5 +1,5 @@
1
1
  module ActiveModel
2
2
  module Otp
3
- VERSION = "1.0.0"
3
+ VERSION = "2.1.1".freeze
4
4
  end
5
5
  end
@@ -0,0 +1,3 @@
1
+ class ActiverecordUser < ActiveRecord::Base
2
+ has_one_time_password counter_based: true
3
+ end
@@ -0,0 +1,10 @@
1
+ class Member
2
+ extend ActiveModel::Callbacks
3
+ include ActiveModel::Validations
4
+ include ActiveModel::OneTimePassword
5
+
6
+ define_model_callbacks :create
7
+ attr_accessor :otp_secret_key, :otp_counter, :email
8
+
9
+ has_one_time_password counter_based: true
10
+ 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,11 +1,14 @@
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
-
9
- has_one_time_password
8
+ attr_accessor :otp_secret_key, :otp_backup_codes, :email
10
9
 
10
+ has_one_time_password one_time_backup_codes: true
11
+ def attributes
12
+ { "otp_secret_key" => otp_secret_key, "email" => email }
13
+ end
11
14
  end
@@ -6,7 +6,6 @@ class Visitor
6
6
  define_model_callbacks :create
7
7
  attr_accessor :otp_token, :email
8
8
 
9
- has_one_time_password :column_name => :otp_token
10
-
9
+ has_one_time_password column_name: :otp_token, length: 4
11
10
  end
12
11
 
@@ -1,13 +1,26 @@
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'
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)
@@ -26,24 +69,66 @@ class OtpTest < MiniTest::Unit::TestCase
26
69
  assert @visitor.authenticate_otp(code, drift: 60)
27
70
  end
28
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
+
29
87
  def test_otp_code
30
88
  assert_match(/^\d{6}$/, @user.otp_code.to_s)
31
- assert_match(/^\d{6}$/, @visitor.otp_code.to_s)
89
+ assert_match(/^\d{4}$/, @visitor.otp_code.to_s)
32
90
  end
33
91
 
34
- def test_otp_code_padding
35
- @user.otp_column = 'kw5jhligwqaiw7jc'
36
- assert_match(/^\d{6}$/, @user.otp_code(time: 2160, padding: true).to_s)
37
- assert_match(/^\d{3}$/, @user.otp_code(time: 2160, padding: false).to_s)
92
+ def test_otp_code_with_specific_length
93
+ assert_match(/^\d{4}$/, @visitor.otp_code(2160).to_s)
94
+ assert_operator(@visitor.otp_code(2160).to_s.length, :<=, 4)
95
+ end
96
+
97
+ def test_otp_code_without_specific_length
98
+ assert_match(/^\d{6}$/, @user.otp_code(2160).to_s)
99
+ assert_operator(@user.otp_code(2160).to_s.length, :<=, 6)
38
100
  end
39
101
 
40
102
  def test_provisioning_uri_with_provided_account
41
- assert_match %r{otpauth://totp/roberto\?secret=\w{16}}, @user.provisioning_uri("roberto")
42
- assert_match %r{otpauth://totp/roberto\?secret=\w{16}}, @visitor.provisioning_uri("roberto")
103
+ assert_match %r{^otpauth://totp/roberto\?secret=\w{32}$}, @user.provisioning_uri("roberto")
104
+ assert_match %r{^otpauth://totp/roberto\?secret=\w{32}$}, @visitor.provisioning_uri("roberto")
105
+ assert_match %r{^otpauth://hotp/roberto\?secret=\w{32}&counter=0$}, @member.provisioning_uri("roberto")
43
106
  end
44
107
 
45
108
  def test_provisioning_uri_with_email_field
46
- assert_match %r{otpauth://totp/roberto@heapsource\.com\?secret=\w{16}}, @user.provisioning_uri
47
- assert_match %r{otpauth://totp/roberto@heapsource\.com\?secret=\w{16}}, @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
111
+ assert_match %r{^otpauth://hotp/\?secret=\w{32}&counter=0$}, @member.provisioning_uri
112
+ end
113
+
114
+ def test_provisioning_uri_with_options
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")
117
+ assert_match %r{^otpauth://totp/Example\:roberto\?secret=\w{32}&issuer=Example$}, @user.provisioning_uri("roberto", issuer: "Example")
118
+ assert_match %r{^otpauth://totp/Example\:roberto\?secret=\w{32}&issuer=Example$}, @visitor.provisioning_uri("roberto", issuer: "Example")
119
+ end
120
+
121
+ def test_regenerate_otp
122
+ secret = @user.otp_column
123
+ @user.otp_regenerate_secret
124
+ assert secret != @user.otp_column
125
+ end
126
+
127
+ def test_hide_secret_key_in_serialize
128
+ refute_match(/otp_secret_key/, @user.to_json)
129
+ end
130
+
131
+ def test_otp_random_secret
132
+ assert_match(/^.{32}$/, @user.class.otp_random_secret)
48
133
  end
49
134
  end
data/test/schema.rb ADDED
@@ -0,0 +1,11 @@
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
+ end
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,89 +1,117 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_model_otp
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 2.1.1
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: 2014-08-01 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
17
17
  requirement: !ruby/object:Gem::Requirement
18
18
  requirements:
19
- - - ! '>='
19
+ - - ">="
20
20
  - !ruby/object:Gem::Version
21
21
  version: '0'
22
22
  type: :runtime
23
23
  prerelease: false
24
24
  version_requirements: !ruby/object:Gem::Requirement
25
25
  requirements:
26
- - - ! '>='
26
+ - - ">="
27
27
  - !ruby/object:Gem::Version
28
28
  version: '0'
29
29
  - !ruby/object:Gem::Dependency
30
30
  name: rotp
31
31
  requirement: !ruby/object:Gem::Requirement
32
32
  requirements:
33
- - - ! '>='
33
+ - - "~>"
34
34
  - !ruby/object:Gem::Version
35
- version: '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: '0'
42
+ version: 6.2.0
43
43
  - !ruby/object:Gem::Dependency
44
- name: bundler
44
+ name: activerecord
45
45
  requirement: !ruby/object:Gem::Requirement
46
46
  requirements:
47
- - - ~>
47
+ - - ">="
48
48
  - !ruby/object:Gem::Version
49
- version: '1.3'
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: '1.3'
56
+ version: '0'
57
57
  - !ruby/object:Gem::Dependency
58
58
  name: rake
59
59
  requirement: !ruby/object:Gem::Requirement
60
60
  requirements:
61
- - - ! '>='
61
+ - - ">="
62
62
  - !ruby/object:Gem::Version
63
63
  version: '0'
64
64
  type: :development
65
65
  prerelease: false
66
66
  version_requirements: !ruby/object:Gem::Requirement
67
67
  requirements:
68
- - - ! '>='
68
+ - - ">="
69
69
  - !ruby/object:Gem::Version
70
70
  version: '0'
71
71
  - !ruby/object:Gem::Dependency
72
72
  name: minitest
73
73
  requirement: !ruby/object:Gem::Requirement
74
74
  requirements:
75
- - - ! '>='
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
+ - - ">="
76
90
  - !ruby/object:Gem::Version
77
91
  version: '0'
78
92
  type: :development
79
93
  prerelease: false
80
94
  version_requirements: !ruby/object:Gem::Requirement
81
95
  requirements:
82
- - - ! '>='
96
+ - - ">="
83
97
  - !ruby/object:Gem::Version
84
98
  version: '0'
85
- description: Adds methods to set and authenticate against one time passwords. Inspired
86
- in AM::SecurePassword"
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,47 +120,61 @@ executables: []
92
120
  extensions: []
93
121
  extra_rdoc_files: []
94
122
  files:
95
- - .gitignore
96
- - .travis.yml
123
+ - ".gitignore"
124
+ - ".travis.yml"
125
+ - Appraisals
97
126
  - CHANGELOG.md
98
127
  - Gemfile
99
128
  - LICENSE.txt
100
129
  - README.md
101
130
  - Rakefile
102
131
  - active_model_otp.gemspec
132
+ - gemfiles/rails_4.2.gemfile
133
+ - gemfiles/rails_5.0.gemfile
134
+ - gemfiles/rails_5.1.gemfile
135
+ - gemfiles/rails_5.2.gemfile
136
+ - gemfiles/rails_6.0.gemfile
137
+ - gemfiles/rails_6.1.gemfile
103
138
  - lib/active_model/one_time_password.rb
104
139
  - lib/active_model/otp/version.rb
105
140
  - lib/active_model_otp.rb
141
+ - test/models/activerecord_user.rb
142
+ - test/models/member.rb
143
+ - test/models/opt_in_two_factor.rb
106
144
  - test/models/user.rb
107
145
  - test/models/visitor.rb
108
146
  - test/one_time_password_test.rb
147
+ - test/schema.rb
109
148
  - test/test_helper.rb
110
149
  homepage: ''
111
150
  licenses:
112
151
  - MIT
113
152
  metadata: {}
114
- post_install_message:
153
+ post_install_message:
115
154
  rdoc_options: []
116
155
  require_paths:
117
156
  - lib
118
157
  required_ruby_version: !ruby/object:Gem::Requirement
119
158
  requirements:
120
- - - ! '>='
159
+ - - ">="
121
160
  - !ruby/object:Gem::Version
122
- version: '0'
161
+ version: '2.3'
123
162
  required_rubygems_version: !ruby/object:Gem::Requirement
124
163
  requirements:
125
- - - ! '>='
164
+ - - ">="
126
165
  - !ruby/object:Gem::Version
127
166
  version: '0'
128
167
  requirements: []
129
- rubyforge_project:
130
- rubygems_version: 2.2.2
131
- signing_key:
168
+ rubygems_version: 3.0.3
169
+ signing_key:
132
170
  specification_version: 4
133
171
  summary: Adds methods to set and authenticate against one time passwords.
134
172
  test_files:
173
+ - test/models/activerecord_user.rb
174
+ - test/models/member.rb
175
+ - test/models/opt_in_two_factor.rb
135
176
  - test/models/user.rb
136
177
  - test/models/visitor.rb
137
178
  - test/one_time_password_test.rb
179
+ - test/schema.rb
138
180
  - test/test_helper.rb