active_model_otp 2.1.0 → 2.3.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: 906ff23803a070afb3df376eb45d4314640c4f4012028d7b7bfc16d9263def54
4
+ data.tar.gz: efde68de226fb2d7a5b2230fc19958502e678776781419ff89532d19d5e9682e
5
5
  SHA512:
6
- metadata.gz: 53612a20dc401b03c051c48450e7161388fed1ab5d2c7d43ebcf12dbec82ca8f12e05d033237f505257d79c4e3ba9861fe5bdae943280e0b625ebb9a0bb95e2d
7
- data.tar.gz: 2cbb80548cccb92ff3ed398671c65aa600adc32de84a95b91287de67a4a7fbac45cf97d2d1d311bc1fc141cdb630a1a4e803a3785e79449ec00fea8ce3db5f2c
6
+ metadata.gz: 2805bf0a8dc09e6699b9617b60f662b4ff50c82c4f952d9e0fdc2a9c2c6c5a5829ea42ceb9bb2167d64d83c050a79272b3224cc0e21ecb93069fde5cc826f9ba
7
+ data.tar.gz: c893d4e40737f2a724e912dd84edfc1cbd3886515757596582436d2694d2ab4836ae7251a522e66c3ac95f31fef4c52f1d1f47836d09d39cb7d259c6c335c77d
@@ -0,0 +1,43 @@
1
+ name: Active Model OTP
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ types: [opened, synchronize, reopened, edited]
8
+
9
+ jobs:
10
+ ci:
11
+ runs-on: ubuntu-latest
12
+
13
+ strategy:
14
+ matrix:
15
+ gemfile: [rails_4.2, rails_5.0, rails_5.1, rails_5.2, rails_6.0, rails_6.1]
16
+ ruby-version: [2.3, 2.4, 2.5, 2.6, 2.7, 3.0]
17
+ exclude:
18
+ - { gemfile: rails_6.0, ruby-version: 2.3 }
19
+ - { gemfile: rails_6.1, ruby-version: 2.3 }
20
+ - { gemfile: rails_6.0, ruby-version: 2.4 }
21
+ - { gemfile: rails_6.1, ruby-version: 2.4 }
22
+ - { gemfile: rails_4.2, ruby-version: 2.7 }
23
+ - { gemfile: rails_4.2, ruby-version: 3.0 }
24
+ - { gemfile: rails_5.0, ruby-version: 3.0 }
25
+ - { gemfile: rails_5.1, ruby-version: 3.0 }
26
+ - { gemfile: rails_5.2, ruby-version: 3.0 }
27
+
28
+ env:
29
+ BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile
30
+
31
+ steps:
32
+ - uses: actions/checkout@v2
33
+
34
+ - name: Install Ruby ${{ matrix.ruby-version }}
35
+ uses: ruby/setup-ruby@v1
36
+ with:
37
+ ruby-version: ${{ matrix.ruby-version }}
38
+
39
+ - name: Install dependencies
40
+ run: bundle install
41
+
42
+ - name: Run tests with Ruby ${{ matrix.ruby-version }} and Gemfile ${{ matrix.gemfile }}
43
+ run: bundle exec rake
data/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
@@ -1,4 +1,4 @@
1
- [![Build Status](https://travis-ci.org/heapsource/active_model_otp.png)](https://travis-ci.org/heapsource/active_model_otp)
1
+ [![Active Model OTP](https://github.com/heapsource/active_model_otp/actions/workflows/active_model_otp.yml/badge.svg?branch=main)](https://github.com/heapsource/active_model_otp/actions/workflows/active_model_otp.yml)
2
2
  [![Gem Version](https://badge.fury.io/rb/active_model_otp.svg)](http://badge.fury.io/rb/active_model_otp)
3
3
  [![Reviewed by Hound](https://img.shields.io/badge/Reviewed_by-Hound-8E64B0.svg)](https://houndci.com)
4
4
 
@@ -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.
@@ -168,6 +213,28 @@ user.provisioning_uri(nil, issuer: 'MYAPP') #=> 'otpauth://totp/hello@heapsource
168
213
 
169
214
  This can then be rendered as a QR Code which can be scanned and added to the users list of OTP credentials.
170
215
 
216
+ ### Setting up a customer interval
217
+
218
+ If you define a custom interval for TOTP codes, just as `has_one_time_password interval: 10` (for example), remember to include the interval also in `provisioning_uri` method. If not defined, the default value is 30 seconds (according to ROTP gem: https://github.com/mdp/rotp/blob/master/lib/rotp/totp.rb#L9)
219
+
220
+ ```ruby
221
+ class User < ApplicationRecord
222
+ has_one_time_password interval: 10 # the interval value is in seconds
223
+ end
224
+
225
+ user = User.new
226
+ user.provisioning_uri("hello", interval: 10) # => 'otpauth://totp/hello?secret=2z6hxkdwi3uvrnpn&period=10'
227
+
228
+ # This code snippet generates OTP codes that expires every 10 seconds.
229
+ ```
230
+
231
+ **Note**: Only some authenticator apps are compatible with custom `period` of tokens, for more details check these links:
232
+
233
+ - https://labanskoller.se/blog/2019/07/11/many-common-mobile-authenticator-apps-accept-qr-codes-for-modes-they-dont-support
234
+ - https://www.ibm.com/docs/en/sva/9.0.7?topic=authentication-configuring-totp-one-time-password-mechanism
235
+
236
+ So, be careful and aware when using custom intervals/periods for your TOTP codes beyond the default 30 seconds :)
237
+
171
238
  ### Working example
172
239
 
173
240
  Scan the following barcode with your phone, using Google Authenticator
@@ -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: "../"
@@ -2,22 +2,50 @@ module ActiveModel
2
2
  module OneTimePassword
3
3
  extend ActiveSupport::Concern
4
4
 
5
+ OTP_DEFAULT_COLUMN_NAME = 'otp_secret_key'.freeze
6
+ OTP_DEFAULT_COUNTER_COLUMN_NAME = 'otp_counter'.freeze
7
+ OTP_DEFAULT_BACKUP_CODES_COLUMN_NAME = 'otp_backup_codes'.freeze
8
+ OTP_DEFAULT_DIGITS = 6
9
+ OTP_DEFAULT_BACKUP_CODES_COUNT = 12
10
+ OTP_COUNTER_ENABLED_BY_DEFAULT = false
11
+ OTP_BACKUP_CODES_ENABLED_BY_DEFAULT = false
12
+
5
13
  module ClassMethods
6
14
  def has_one_time_password(options = {})
7
- cattr_accessor :otp_column_name, :otp_counter_column_name
8
- class_attribute :otp_digits, :otp_counter_based
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
15
+ cattr_accessor :otp_column_name, :otp_counter_column_name,
16
+ :otp_backup_codes_column_name
17
+ class_attribute :otp_digits, :otp_counter_based,
18
+ :otp_backup_codes_count, :otp_one_time_backup_codes,
19
+ :otp_interval
20
+
21
+ self.otp_column_name = (
22
+ options[:column_name] || OTP_DEFAULT_COLUMN_NAME
23
+ ).to_s
24
+ self.otp_digits = options[:length] || OTP_DEFAULT_DIGITS
25
+ self.otp_counter_based = (
26
+ options[:counter_based] || OTP_COUNTER_ENABLED_BY_DEFAULT
27
+ )
28
+ self.otp_counter_column_name = (
29
+ options[:counter_column_name] || OTP_DEFAULT_COUNTER_COLUMN_NAME
30
+ ).to_s
31
+ self.otp_interval = options[:interval]
32
+ self.otp_backup_codes_column_name = (
33
+ options[:backup_codes_column_name] ||
34
+ OTP_DEFAULT_BACKUP_CODES_COLUMN_NAME
35
+ ).to_s
36
+ self.otp_backup_codes_count = (
37
+ options[:backup_codes_count] || OTP_DEFAULT_BACKUP_CODES_COUNT
38
+ )
39
+ self.otp_one_time_backup_codes = (
40
+ options[:one_time_backup_codes] || OTP_BACKUP_CODES_ENABLED_BY_DEFAULT
41
+ )
15
42
 
16
43
  include InstanceMethodsOnActivation
17
44
 
18
- before_create(options.slice(:if, :unless)) do
45
+ before_create(**options.slice(:if, :unless)) do
19
46
  self.otp_regenerate_secret if !otp_column
20
47
  self.otp_regenerate_counter if otp_counter_based && !otp_counter
48
+ otp_regenerate_backup_codes if backup_codes_enabled?
21
49
  end
22
50
 
23
51
  if respond_to?(:attributes_protected_by_default)
@@ -44,38 +72,21 @@ module ActiveModel
44
72
  end
45
73
 
46
74
  def authenticate_otp(code, options = {})
75
+ return false if code.nil? || code.empty?
76
+ return true if backup_codes_enabled? && authenticate_backup_code(code)
77
+
47
78
  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
79
+ otp_counter == authenticate_hotp(code, options)
55
80
  else
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
81
+ authenticate_totp(code, options).present?
62
82
  end
63
83
  end
64
84
 
65
85
  def otp_code(options = {})
66
86
  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)
87
+ hotp_code(options)
72
88
  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)
89
+ totp_code(options)
79
90
  end
80
91
  end
81
92
 
@@ -84,9 +95,13 @@ module ActiveModel
84
95
  account ||= ""
85
96
 
86
97
  if otp_counter_based
87
- ROTP::HOTP.new(otp_column, options).provisioning_uri(account)
98
+ ROTP::HOTP
99
+ .new(otp_column, options)
100
+ .provisioning_uri(account, self.otp_counter)
88
101
  else
89
- ROTP::TOTP.new(otp_column, options).provisioning_uri(account)
102
+ ROTP::TOTP
103
+ .new(otp_column, options)
104
+ .provisioning_uri(account)
90
105
  end
91
106
  end
92
107
 
@@ -120,6 +135,79 @@ module ActiveModel
120
135
  options[:except] << self.class.otp_column_name
121
136
  super(options)
122
137
  end
138
+
139
+ def otp_regenerate_backup_codes
140
+ otp = ROTP::OTP.new(otp_column)
141
+ backup_codes = Array.new(self.class.otp_backup_codes_count) do
142
+ otp.generate_otp((SecureRandom.random_number(9e5) + 1e5).to_i)
143
+ end
144
+
145
+ public_send("#{self.class.otp_backup_codes_column_name}=", backup_codes)
146
+ end
147
+
148
+ def backup_codes_enabled?
149
+ self.class.attribute_method?(self.class.otp_backup_codes_column_name)
150
+ end
151
+
152
+ private
153
+
154
+ def authenticate_hotp(code, options = {})
155
+ hotp = ROTP::HOTP.new(otp_column, digits: otp_digits)
156
+ result = hotp.verify(code, otp_counter)
157
+ if result && options[:auto_increment]
158
+ self.otp_counter += 1
159
+ save if respond_to?(:changed?) && !new_record?
160
+ end
161
+ result
162
+ end
163
+
164
+ def authenticate_totp(code, options = {})
165
+ totp = ROTP::TOTP.new(
166
+ otp_column,
167
+ digits: otp_digits,
168
+ interval: otp_interval
169
+ )
170
+ if (drift = options[:drift])
171
+ totp.verify(code, drift_behind: drift)
172
+ else
173
+ totp.verify(code)
174
+ end
175
+ end
176
+
177
+ def hotp_code(options = {})
178
+ if options[:auto_increment]
179
+ self.otp_counter += 1
180
+ save if respond_to?(:changed?) && !new_record?
181
+ end
182
+ ROTP::HOTP.new(otp_column, digits: otp_digits).at(otp_counter)
183
+ end
184
+
185
+ def totp_code(options = {})
186
+ time = if options.is_a?(Hash)
187
+ options.fetch(:time, Time.now)
188
+ else
189
+ options
190
+ end
191
+ ROTP::TOTP.new(
192
+ otp_column,
193
+ digits: otp_digits,
194
+ interval: otp_interval
195
+ ).at(time)
196
+ end
197
+
198
+ def authenticate_backup_code(code)
199
+ backup_codes_column_name = self.class.otp_backup_codes_column_name
200
+ backup_codes = public_send(backup_codes_column_name)
201
+ return false unless backup_codes.present? && backup_codes.include?(code)
202
+
203
+ if self.class.otp_one_time_backup_codes
204
+ backup_codes.delete(code)
205
+ public_send("#{backup_codes_column_name}=", backup_codes)
206
+ save if respond_to?(:changed?) && !new_record?
207
+ end
208
+
209
+ true
210
+ end
123
211
  end
124
212
  end
125
213
  end
@@ -1,5 +1,5 @@
1
1
  module ActiveModel
2
2
  module Otp
3
- VERSION = "2.1.0"
3
+ VERSION = '2.3.1'.freeze
4
4
  end
5
5
  end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DefaultIntervalUser < ActiveRecord::Base
4
+ has_one_time_password interval: 500
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class IntervalUser < ActiveRecord::Base
4
+ has_one_time_password interval: 2
5
+ end
data/test/models/user.rb CHANGED
@@ -1,14 +1,14 @@
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
9
+
10
+ has_one_time_password one_time_backup_codes: true
10
11
 
11
- has_one_time_password
12
12
  def attributes
13
13
  { "otp_secret_key" => otp_secret_key, "email" => email }
14
14
  end
@@ -1,6 +1,8 @@
1
- require "test_helper"
1
+ # frozen_string_literal: true
2
2
 
3
- class OtpTest < MiniTest::Unit::TestCase
3
+ require 'test_helper'
4
+
5
+ class OtpTest < MiniTest::Test
4
6
  def setup
5
7
  @user = User.new
6
8
  @user.email = 'roberto@heapsource.com'
@@ -31,6 +33,23 @@ class OtpTest < MiniTest::Unit::TestCase
31
33
  assert @visitor.authenticate_otp(code)
32
34
  end
33
35
 
36
+ def test_authenticate_with_otp_passing_false_or_empty_codes
37
+ refute @user.authenticate_otp(nil)
38
+ refute @user.authenticate_otp('')
39
+
40
+ refute @visitor.authenticate_otp(nil)
41
+ refute @visitor.authenticate_otp('')
42
+
43
+ refute @member.authenticate_otp(nil)
44
+ refute @member.authenticate_otp('')
45
+
46
+ refute @ar_user.authenticate_otp(nil)
47
+ refute @ar_user.authenticate_otp('')
48
+
49
+ refute @opt_in.authenticate_otp(nil)
50
+ refute @opt_in.authenticate_otp('')
51
+ end
52
+
34
53
  def test_counter_based_otp
35
54
  code = @member.otp_code
36
55
  assert @member.authenticate_otp(code)
@@ -58,15 +77,30 @@ class OtpTest < MiniTest::Unit::TestCase
58
77
 
59
78
  @opt_in.otp_regenerate_secret
60
79
  code = @opt_in.otp_code
61
- assert @opt_in.authenticate_otp(code)
80
+ assert_equal true, @opt_in.authenticate_otp(code)
62
81
  end
63
82
 
64
83
  def test_authenticate_with_otp_when_drift_is_allowed
65
84
  code = @user.otp_code(Time.now - 30)
66
- assert @user.authenticate_otp(code, drift: 60)
85
+ assert_equal true, @user.authenticate_otp(code, drift: 60)
67
86
 
68
87
  code = @visitor.otp_code(Time.now - 30)
69
- assert @visitor.authenticate_otp(code, drift: 60)
88
+ assert_equal true, @visitor.authenticate_otp(code, drift: 60)
89
+ end
90
+
91
+ def test_authenticate_with_backup_code
92
+ backup_code = @user.public_send(@user.otp_backup_codes_column_name).first
93
+ assert_equal true, @user.authenticate_otp(backup_code)
94
+
95
+ backup_code = @user.public_send(@user.otp_backup_codes_column_name).last
96
+ @user.otp_regenerate_backup_codes
97
+ assert_equal true, !@user.authenticate_otp(backup_code)
98
+ end
99
+
100
+ def test_authenticate_with_one_time_backup_code
101
+ backup_code = @user.public_send(@user.otp_backup_codes_column_name).first
102
+ assert_equal true, @user.authenticate_otp(backup_code)
103
+ assert_equal true, !@user.authenticate_otp(backup_code)
70
104
  end
71
105
 
72
106
  def test_otp_code
@@ -85,22 +119,51 @@ class OtpTest < MiniTest::Unit::TestCase
85
119
  end
86
120
 
87
121
  def test_provisioning_uri_with_provided_account
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")
122
+ totp = %r{^otpauth://totp/roberto\?secret=\w{32}$}
123
+ hotp = %r{^otpauth://hotp/roberto\?secret=\w{32}&counter=1$}
124
+
125
+ assert_match totp, @user.provisioning_uri('roberto')
126
+ assert_match totp, @visitor.provisioning_uri('roberto')
127
+ assert_match hotp, @member.provisioning_uri('roberto')
91
128
  end
92
129
 
93
130
  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
96
- assert_match %r{^otpauth://hotp/\?secret=\w{32}&counter=0$}, @member.provisioning_uri
131
+ totp = %r{^otpauth://totp/roberto%40heapsource\.com\?secret=\w{32}$}
132
+ hotp = %r{^otpauth://hotp/\?secret=\w{32}&counter=1$}
133
+
134
+ assert_match totp, @user.provisioning_uri
135
+ assert_match totp, @visitor.provisioning_uri
136
+ assert_match hotp, @member.provisioning_uri
97
137
  end
98
138
 
99
139
  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")
140
+ account = %r{
141
+ ^otpauth://totp/Example\:roberto\?secret=\w{32}&issuer=Example$
142
+ }x
143
+
144
+ email = %r{
145
+ ^otpauth://totp/Example\:roberto%40heapsource\.com\?secret=\w{32}
146
+ &issuer=Example$
147
+ }x
148
+
149
+ assert_match(
150
+ account, @user.provisioning_uri('roberto', issuer: 'Example')
151
+ )
152
+
153
+ assert_match(
154
+ account, @visitor.provisioning_uri('roberto', issuer: 'Example')
155
+ )
156
+
157
+ assert_match email, @user.provisioning_uri(nil, issuer: 'Example')
158
+ assert_match email, @visitor.provisioning_uri(nil, issuer: 'Example')
159
+ end
160
+
161
+ def test_provisioning_uri_with_incremented_counter
162
+ 2.times { @member.otp_code(auto_increment: true) }
163
+
164
+ hotp = %r{^otpauth://hotp/\?secret=\w{32}&counter=3$}
165
+
166
+ assert_match hotp, @member.provisioning_uri
104
167
  end
105
168
 
106
169
  def test_regenerate_otp
@@ -111,10 +174,29 @@ class OtpTest < MiniTest::Unit::TestCase
111
174
 
112
175
  def test_hide_secret_key_in_serialize
113
176
  refute_match(/otp_secret_key/, @user.to_json)
114
- refute_match(/otp_secret_key/, @user.to_xml)
115
177
  end
116
178
 
117
179
  def test_otp_random_secret
118
- assert_match /^.{32}$/, @user.class.otp_random_secret
180
+ assert_match(/^.{32}$/, @user.class.otp_random_secret)
181
+ end
182
+
183
+ def test_otp_interval
184
+ @interval_user = IntervalUser.new
185
+ @interval_user.email = 'roberto@heapsource.com'
186
+ @interval_user.run_callbacks :create
187
+ otp_code = @interval_user.otp_code
188
+ 2.times { assert_match(otp_code, @interval_user.otp_code) }
189
+ sleep 5
190
+ refute_match(otp_code, @interval_user.otp_code)
191
+ end
192
+
193
+ def test_otp_default_interval
194
+ @default_interval_user = DefaultIntervalUser.new
195
+ @default_interval_user.email = 'roberto@heapsource.com'
196
+ @default_interval_user.run_callbacks :create
197
+ otp_code = @default_interval_user.otp_code
198
+ 2.times { assert_match(otp_code, @default_interval_user.otp_code) }
199
+ sleep 5
200
+ assert_match(otp_code, @default_interval_user.otp_code)
119
201
  end
120
202
  end
data/test/schema.rb CHANGED
@@ -8,4 +8,20 @@ ActiveRecord::Schema.define do
8
8
  t.string :otp_secret_key
9
9
  t.timestamps
10
10
  end
11
+
12
+ create_table :interval_users, force: true do |t|
13
+ t.string :key
14
+ t.string :email
15
+ t.integer :otp_counter
16
+ t.string :otp_secret_key
17
+ t.timestamps
18
+ end
19
+
20
+ create_table :default_interval_users, force: true do |t|
21
+ t.string :key
22
+ t.string :email
23
+ t.integer :otp_counter
24
+ t.string :otp_secret_key
25
+ t.timestamps
26
+ end
11
27
  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.3.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-10-22 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:
@@ -120,8 +120,8 @@ executables: []
120
120
  extensions: []
121
121
  extra_rdoc_files: []
122
122
  files:
123
+ - ".github/workflows/active_model_otp.yml"
123
124
  - ".gitignore"
124
- - ".travis.yml"
125
125
  - Appraisals
126
126
  - CHANGELOG.md
127
127
  - Gemfile
@@ -134,10 +134,13 @@ 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
140
141
  - test/models/activerecord_user.rb
142
+ - test/models/default_interval_user.rb
143
+ - test/models/interval_user.rb
141
144
  - test/models/member.rb
142
145
  - test/models/opt_in_two_factor.rb
143
146
  - test/models/user.rb
@@ -170,6 +173,8 @@ specification_version: 4
170
173
  summary: Adds methods to set and authenticate against one time passwords.
171
174
  test_files:
172
175
  - test/models/activerecord_user.rb
176
+ - test/models/default_interval_user.rb
177
+ - test/models/interval_user.rb
173
178
  - test/models/member.rb
174
179
  - test/models/opt_in_two_factor.rb
175
180
  - test/models/user.rb
data/.travis.yml DELETED
@@ -1,26 +0,0 @@
1
- rvm:
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
13
- matrix:
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"
25
- notifications:
26
- email: false