active_model_otp 2.3.0 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2a57a05fda7ae2023dc877a96228e00e5dbca948bc8d17c1058232a42086e285
4
- data.tar.gz: 9d3a965117edbf33fb77585126be84e2fd44b613bbbc435c74627b198b87ee5f
3
+ metadata.gz: 98c3fad16e797e0483e6aeebcdae72e868dd699b82f07400ad0ee9b0261dbd2a
4
+ data.tar.gz: 3454ea7c190b0f7f69b50cf617c132a46e1a22f7f5554e4159acec61dd40979f
5
5
  SHA512:
6
- metadata.gz: 847accabdd4eff2942a917f497197b3be90bb5cf9e9caefb0cd753635994e3bb8b730aab0d9387aa13c4e929793816333d094b620037a8f9c002a7cc3f3ebdcb
7
- data.tar.gz: 6a47a8a378bdb2d9155a165e503f672b5c57576c7f9d4fdef271767422a2d9804a141252707cb0181b02c36a91024d1867b9114cdbc95e475c2e4b292f750b65
6
+ metadata.gz: 93a2cec634c0b00a4480598f0dc5fe8bfcf27a9dcc35fb6603eb7bbdfb90fc4f85f94443fbe4b9df9acb6113facd7c14bc12368f0ad15f4df2c0cd5b5498f6bf
7
+ data.tar.gz: 232296291239477d07f49ba75bc9db6288a8a3e039f6b1bc93bb3f9ce57d07d8e3624450ebc425e4b7532073f5937506f97f67ec014cc9c6e2312c30aeeb755e
@@ -9,22 +9,48 @@ on:
9
9
  jobs:
10
10
  ci:
11
11
  runs-on: ubuntu-latest
12
-
12
+
13
13
  strategy:
14
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]
15
+ gemfile: [rails_4.2, rails_5.0, rails_5.1, rails_5.2, rails_6.0, rails_6.1, rails_7.0]
16
+ ruby-version: [2.3, 2.4, 2.5, 2.6, 2.7, 3.0, 3.1, 3.2]
17
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 }
18
+ - { gemfile: rails_4.2, ruby-version: 2.5 }
19
+ - { gemfile: rails_4.2, ruby-version: 2.6 }
22
20
  - { gemfile: rails_4.2, ruby-version: 2.7 }
23
21
  - { gemfile: rails_4.2, ruby-version: 3.0 }
22
+ - { gemfile: rails_4.2, ruby-version: 3.1 }
23
+ - { gemfile: rails_4.2, ruby-version: 3.2 }
24
+ - { gemfile: rails_5.0, ruby-version: 2.5 }
25
+ - { gemfile: rails_5.0, ruby-version: 2.6 }
26
+ - { gemfile: rails_5.0, ruby-version: 2.7 }
24
27
  - { gemfile: rails_5.0, ruby-version: 3.0 }
28
+ - { gemfile: rails_5.0, ruby-version: 3.1 }
29
+ - { gemfile: rails_5.0, ruby-version: 3.2 }
30
+ - { gemfile: rails_5.1, ruby-version: 2.6 }
31
+ - { gemfile: rails_5.1, ruby-version: 2.7 }
25
32
  - { gemfile: rails_5.1, ruby-version: 3.0 }
33
+ - { gemfile: rails_5.1, ruby-version: 3.1 }
34
+ - { gemfile: rails_5.1, ruby-version: 3.2 }
35
+ - { gemfile: rails_5.2, ruby-version: 2.7 }
26
36
  - { gemfile: rails_5.2, ruby-version: 3.0 }
27
-
37
+ - { gemfile: rails_5.2, ruby-version: 3.1 }
38
+ - { gemfile: rails_5.2, ruby-version: 3.2 }
39
+ - { gemfile: rails_6.0, ruby-version: 2.3 }
40
+ - { gemfile: rails_6.0, ruby-version: 2.4 }
41
+ - { gemfile: rails_6.0, ruby-version: 3.0 }
42
+ - { gemfile: rails_6.0, ruby-version: 3.1 }
43
+ - { gemfile: rails_6.0, ruby-version: 3.2 }
44
+ - { gemfile: rails_6.1, ruby-version: 2.3 }
45
+ - { gemfile: rails_6.1, ruby-version: 2.4 }
46
+ - { gemfile: rails_6.1, ruby-version: 3.0 }
47
+ - { gemfile: rails_6.1, ruby-version: 3.1 }
48
+ - { gemfile: rails_6.1, ruby-version: 3.2 }
49
+ - { gemfile: rails_7.0, ruby-version: 2.3 }
50
+ - { gemfile: rails_7.0, ruby-version: 2.4 }
51
+ - { gemfile: rails_7.0, ruby-version: 2.5 }
52
+ - { gemfile: rails_7.0, ruby-version: 2.6 }
53
+
28
54
  env:
29
55
  BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile
30
56
 
data/Appraisals CHANGED
@@ -34,3 +34,10 @@ appraise "rails-6.1" do
34
34
  gem "activemodel-serializers-xml"
35
35
  gem "sqlite3", "~> 1.4"
36
36
  end
37
+
38
+ appraise "rails-7.0" do
39
+ gem "activerecord", "~> 7.0"
40
+ gem "activemodel", "~> 7.0"
41
+ gem "activemodel-serializers-xml"
42
+ gem "sqlite3", "~> 1.6"
43
+ 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
 
@@ -98,9 +98,31 @@ sleep 30 # lets wait again
98
98
  user.authenticate_otp('186522', drift: 60) # => true
99
99
  ```
100
100
 
101
+ ### Preventing reuse of Time based OTP's
102
+
103
+ By keeping track of the last time a user's OTP was verified, we can prevent token reuse during the interval window (default 30 seconds). It is useful with SMS, that is commonly used in combination with `drift` to extend the life of the code.
104
+
105
+ ```ruby
106
+ rails g migration AddLastOtpAtToUsers last_otp_at:integer
107
+ =>
108
+ invoke active_record
109
+ create db/migrate/20220407010931_add_last_otp_at_to_users.rb
110
+ ```
111
+
112
+ ```ruby
113
+ class User < ApplicationRecord
114
+ has_one_time_password after_column_name: :last_otp_at
115
+ end
116
+ ```
117
+
118
+ ```ruby
119
+ user.authenticate_otp('186522') # => true
120
+ user.authenticate_otp('186522') # => false
121
+ ```
122
+
101
123
  ## Counter based OTP
102
124
 
103
- An additonal counter field is required in our ``User`` Model
125
+ An additional counter field is required in our ``User`` Model
104
126
 
105
127
  ```ruby
106
128
  rails g migration AddCounterForOtpToUsers otp_counter:integer
@@ -213,7 +235,7 @@ user.provisioning_uri(nil, issuer: 'MYAPP') #=> 'otpauth://totp/hello@heapsource
213
235
 
214
236
  This can then be rendered as a QR Code which can be scanned and added to the users list of OTP credentials.
215
237
 
216
- ### Setting up a customer interval
238
+ ### Setting up a customer interval
217
239
 
218
240
  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
241
 
@@ -0,0 +1,10 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activerecord", "~> 7.0"
6
+ gem "activemodel", "~> 7.0"
7
+ gem "activemodel-serializers-xml"
8
+ gem "sqlite3", "~> 1.6"
9
+
10
+ gemspec path: "../"
@@ -13,32 +13,20 @@ module ActiveModel
13
13
  module ClassMethods
14
14
  def has_one_time_password(options = {})
15
15
  cattr_accessor :otp_column_name, :otp_counter_column_name,
16
- :otp_backup_codes_column_name
16
+ :otp_backup_codes_column_name, :otp_after_column_name
17
17
  class_attribute :otp_digits, :otp_counter_based,
18
18
  :otp_backup_codes_count, :otp_one_time_backup_codes,
19
19
  :otp_interval
20
20
 
21
- self.otp_column_name = (
22
- options[:column_name] || OTP_DEFAULT_COLUMN_NAME
23
- ).to_s
21
+ self.otp_column_name = (options[:column_name] || OTP_DEFAULT_COLUMN_NAME).to_s
24
22
  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
23
+ self.otp_counter_based = options[:counter_based] || OTP_COUNTER_ENABLED_BY_DEFAULT
24
+ self.otp_counter_column_name = (options[:counter_column_name] || OTP_DEFAULT_COUNTER_COLUMN_NAME).to_s
31
25
  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
- )
26
+ self.otp_after_column_name = options[:after_column_name]
27
+ self.otp_backup_codes_column_name = (options[:backup_codes_column_name] || OTP_DEFAULT_BACKUP_CODES_COLUMN_NAME).to_s
28
+ self.otp_backup_codes_count = options[:backup_codes_count] || OTP_DEFAULT_BACKUP_CODES_COUNT
29
+ self.otp_one_time_backup_codes = options[:one_time_backup_codes] || OTP_BACKUP_CODES_ENABLED_BY_DEFAULT
42
30
 
43
31
  include InstanceMethodsOnActivation
44
32
 
@@ -72,6 +60,7 @@ module ActiveModel
72
60
  end
73
61
 
74
62
  def authenticate_otp(code, options = {})
63
+ return false if code.nil? || code.empty?
75
64
  return true if backup_codes_enabled? && authenticate_backup_code(code)
76
65
 
77
66
  if otp_counter_based
@@ -94,13 +83,9 @@ module ActiveModel
94
83
  account ||= ""
95
84
 
96
85
  if otp_counter_based
97
- ROTP::HOTP
98
- .new(otp_column, options)
99
- .provisioning_uri(account, self.otp_counter)
86
+ ROTP::HOTP.new(otp_column, options).provisioning_uri(account, self.otp_counter)
100
87
  else
101
- ROTP::TOTP
102
- .new(otp_column, options)
103
- .provisioning_uri(account)
88
+ ROTP::TOTP.new(otp_column, options).provisioning_uri(account)
104
89
  end
105
90
  end
106
91
 
@@ -132,6 +117,7 @@ module ActiveModel
132
117
  options ||= {}
133
118
  options[:except] = Array(options[:except])
134
119
  options[:except] << self.class.otp_column_name
120
+
135
121
  super(options)
136
122
  end
137
123
 
@@ -153,45 +139,42 @@ module ActiveModel
153
139
  def authenticate_hotp(code, options = {})
154
140
  hotp = ROTP::HOTP.new(otp_column, digits: otp_digits)
155
141
  result = hotp.verify(code, otp_counter)
142
+
156
143
  if result && options[:auto_increment]
157
144
  self.otp_counter += 1
158
145
  save if respond_to?(:changed?) && !new_record?
159
146
  end
147
+
160
148
  result
161
149
  end
162
150
 
163
151
  def authenticate_totp(code, options = {})
164
- totp = ROTP::TOTP.new(
165
- otp_column,
166
- digits: otp_digits,
167
- interval: otp_interval
168
- )
169
- if (drift = options[:drift])
170
- totp.verify(code, drift_behind: drift)
171
- else
172
- totp.verify(code)
152
+ totp = ROTP::TOTP.new(otp_column, digits: otp_digits, interval: otp_interval)
153
+
154
+ otp_after = public_send(otp_after_column_name) if otp_after_column_name_enabled?
155
+
156
+ totp.verify(code, drift_behind: options[:drift] || 0, after: otp_after).tap do |updated_last_otp_at|
157
+ updated_last_otp_at && otp_after_column_name_enabled? && update(otp_after_column_name => updated_last_otp_at)
173
158
  end
174
159
  end
175
160
 
161
+ def otp_after_column_name_enabled?
162
+ otp_after_column_name && respond_to?(otp_after_column_name)
163
+ end
164
+
176
165
  def hotp_code(options = {})
177
166
  if options[:auto_increment]
178
167
  self.otp_counter += 1
179
168
  save if respond_to?(:changed?) && !new_record?
180
169
  end
170
+
181
171
  ROTP::HOTP.new(otp_column, digits: otp_digits).at(otp_counter)
182
172
  end
183
173
 
184
174
  def totp_code(options = {})
185
- time = if options.is_a?(Hash)
186
- options.fetch(:time, Time.now)
187
- else
188
- options
189
- end
190
- ROTP::TOTP.new(
191
- otp_column,
192
- digits: otp_digits,
193
- interval: otp_interval
194
- ).at(time)
175
+ time = options.is_a?(Hash) ? options.fetch(:time, Time.now) : options
176
+
177
+ ROTP::TOTP.new(otp_column, digits: otp_digits, interval: otp_interval).at(time)
195
178
  end
196
179
 
197
180
  def authenticate_backup_code(code)
@@ -1,5 +1,5 @@
1
1
  module ActiveModel
2
2
  module Otp
3
- VERSION = '2.3.0'.freeze
3
+ VERSION = '2.3.2'.freeze
4
4
  end
5
5
  end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AfterUser < ActiveRecord::Base
4
+ has_one_time_password after_column_name: :last_otp_at
5
+ end
@@ -3,6 +3,8 @@
3
3
  require 'test_helper'
4
4
 
5
5
  class OtpTest < MiniTest::Test
6
+ include ActiveSupport::Testing::TimeHelpers
7
+
6
8
  def setup
7
9
  @user = User.new
8
10
  @user.email = 'roberto@heapsource.com'
@@ -23,6 +25,10 @@ class OtpTest < MiniTest::Test
23
25
  @opt_in = OptInTwoFactor.new
24
26
  @opt_in.email = 'roberto@heapsource.com'
25
27
  @opt_in.run_callbacks :create
28
+
29
+ @after_user = AfterUser.new
30
+ @after_user.email = 'roberto@heapsource.com'
31
+ @after_user.run_callbacks :create
26
32
  end
27
33
 
28
34
  def test_authenticate_with_otp
@@ -33,6 +39,23 @@ class OtpTest < MiniTest::Test
33
39
  assert @visitor.authenticate_otp(code)
34
40
  end
35
41
 
42
+ def test_authenticate_with_otp_passing_false_or_empty_codes
43
+ refute @user.authenticate_otp(nil)
44
+ refute @user.authenticate_otp('')
45
+
46
+ refute @visitor.authenticate_otp(nil)
47
+ refute @visitor.authenticate_otp('')
48
+
49
+ refute @member.authenticate_otp(nil)
50
+ refute @member.authenticate_otp('')
51
+
52
+ refute @ar_user.authenticate_otp(nil)
53
+ refute @ar_user.authenticate_otp('')
54
+
55
+ refute @opt_in.authenticate_otp(nil)
56
+ refute @opt_in.authenticate_otp('')
57
+ end
58
+
36
59
  def test_counter_based_otp
37
60
  code = @member.otp_code
38
61
  assert @member.authenticate_otp(code)
@@ -71,6 +94,18 @@ class OtpTest < MiniTest::Test
71
94
  assert_equal true, @visitor.authenticate_otp(code, drift: 60)
72
95
  end
73
96
 
97
+ def test_authenticate_with_otp_when_after_is_allowed
98
+ code = @user.otp_code
99
+ assert_equal true, @user.authenticate_otp(code)
100
+ assert_equal true, @user.authenticate_otp(code)
101
+
102
+ code = @after_user.otp_code
103
+ assert_equal true, @after_user.authenticate_otp(code)
104
+ assert_equal false, @after_user.authenticate_otp(code)
105
+ assert_equal false, @after_user.authenticate_otp('1111111')
106
+ assert_equal false, @after_user.authenticate_otp(code)
107
+ end
108
+
74
109
  def test_authenticate_with_backup_code
75
110
  backup_code = @user.public_send(@user.otp_backup_codes_column_name).first
76
111
  assert_equal true, @user.authenticate_otp(backup_code)
@@ -98,7 +133,7 @@ class OtpTest < MiniTest::Test
98
133
 
99
134
  def test_otp_code_without_specific_length
100
135
  assert_match(/^\d{6}$/, @user.otp_code(2160).to_s)
101
- assert_operator(@user.otp_code(2160).to_s.length, :<=, 6)
136
+ assert @user.otp_code(2160).to_s.length <= 6
102
137
  end
103
138
 
104
139
  def test_provisioning_uri_with_provided_account
@@ -129,13 +164,8 @@ class OtpTest < MiniTest::Test
129
164
  &issuer=Example$
130
165
  }x
131
166
 
132
- assert_match(
133
- account, @user.provisioning_uri('roberto', issuer: 'Example')
134
- )
135
-
136
- assert_match(
137
- account, @visitor.provisioning_uri('roberto', issuer: 'Example')
138
- )
167
+ assert_match account, @user.provisioning_uri('roberto', issuer: 'Example')
168
+ assert_match account, @visitor.provisioning_uri('roberto', issuer: 'Example')
139
169
 
140
170
  assert_match email, @user.provisioning_uri(nil, issuer: 'Example')
141
171
  assert_match email, @visitor.provisioning_uri(nil, issuer: 'Example')
@@ -169,8 +199,10 @@ class OtpTest < MiniTest::Test
169
199
  @interval_user.run_callbacks :create
170
200
  otp_code = @interval_user.otp_code
171
201
  2.times { assert_match(otp_code, @interval_user.otp_code) }
172
- sleep 5
173
- refute_match(otp_code, @interval_user.otp_code)
202
+
203
+ travel 5.seconds do
204
+ refute_match(otp_code, @interval_user.otp_code)
205
+ end
174
206
  end
175
207
 
176
208
  def test_otp_default_interval
@@ -178,8 +210,11 @@ class OtpTest < MiniTest::Test
178
210
  @default_interval_user.email = 'roberto@heapsource.com'
179
211
  @default_interval_user.run_callbacks :create
180
212
  otp_code = @default_interval_user.otp_code
213
+
181
214
  2.times { assert_match(otp_code, @default_interval_user.otp_code) }
182
- sleep 5
183
- assert_match(otp_code, @default_interval_user.otp_code)
215
+
216
+ travel 5.seconds do
217
+ assert_match(otp_code, @default_interval_user.otp_code)
218
+ end
184
219
  end
185
220
  end
data/test/schema.rb CHANGED
@@ -24,4 +24,13 @@ ActiveRecord::Schema.define do
24
24
  t.string :otp_secret_key
25
25
  t.timestamps
26
26
  end
27
+
28
+ create_table :after_users, force: true do |t|
29
+ t.string :key
30
+ t.string :email
31
+ t.integer :otp_counter
32
+ t.string :otp_secret_key
33
+ t.integer :last_otp_at
34
+ t.timestamps
35
+ end
27
36
  end
data/test/test_helper.rb CHANGED
@@ -9,6 +9,7 @@ require "active_model_otp"
9
9
  require "minitest/autorun"
10
10
  require "minitest/unit"
11
11
  require "active_record"
12
+ require "active_support/testing/time_helpers"
12
13
 
13
14
  begin
14
15
  require "activemodel-serializers-xml"
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.3.0
4
+ version: 2.3.2
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: 2021-06-22 00:00:00.000000000 Z
13
+ date: 2023-04-26 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: activemodel
@@ -135,10 +135,12 @@ files:
135
135
  - gemfiles/rails_5.2.gemfile
136
136
  - gemfiles/rails_6.0.gemfile
137
137
  - gemfiles/rails_6.1.gemfile
138
+ - gemfiles/rails_7.0.gemfile
138
139
  - lib/active_model/one_time_password.rb
139
140
  - lib/active_model/otp/version.rb
140
141
  - lib/active_model_otp.rb
141
142
  - test/models/activerecord_user.rb
143
+ - test/models/after_user.rb
142
144
  - test/models/default_interval_user.rb
143
145
  - test/models/interval_user.rb
144
146
  - test/models/member.rb
@@ -167,12 +169,13 @@ required_rubygems_version: !ruby/object:Gem::Requirement
167
169
  - !ruby/object:Gem::Version
168
170
  version: '0'
169
171
  requirements: []
170
- rubygems_version: 3.0.3
172
+ rubygems_version: 3.4.1
171
173
  signing_key:
172
174
  specification_version: 4
173
175
  summary: Adds methods to set and authenticate against one time passwords.
174
176
  test_files:
175
177
  - test/models/activerecord_user.rb
178
+ - test/models/after_user.rb
176
179
  - test/models/default_interval_user.rb
177
180
  - test/models/interval_user.rb
178
181
  - test/models/member.rb