orthodox 0.2.4 → 0.3.0

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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/lib/generators/authentication/USAGE +14 -0
  3. data/lib/generators/authentication/authentication_generator.rb +214 -0
  4. data/lib/generators/authentication/templates/controllers/concerns/authentication.rb.erb +110 -0
  5. data/lib/generators/authentication/templates/controllers/concerns/two_factor_authentication.rb +40 -0
  6. data/lib/generators/authentication/templates/controllers/password_resets_controller.rb.erb +54 -0
  7. data/lib/generators/authentication/templates/controllers/sessions_controller.rb.erb +36 -0
  8. data/lib/generators/authentication/templates/controllers/tfa_sessions_controller.rb.erb +48 -0
  9. data/lib/generators/authentication/templates/controllers/tfas_controller.rb.erb +38 -0
  10. data/lib/generators/authentication/templates/helpers/otp_credentials_helper.rb +33 -0
  11. data/lib/generators/authentication/templates/javascript/tfa_forms.js +19 -0
  12. data/lib/generators/authentication/templates/models/concerns/authenticateable.rb +37 -0
  13. data/lib/generators/authentication/templates/models/concerns/otpable.rb +26 -0
  14. data/lib/generators/authentication/templates/models/concerns/password_resetable.rb +19 -0
  15. data/lib/generators/authentication/templates/models/otp_credential.rb.erb +133 -0
  16. data/lib/generators/authentication/templates/models/password_reset_token.rb +64 -0
  17. data/lib/generators/authentication/templates/models/session.rb.erb +80 -0
  18. data/lib/generators/authentication/templates/models/tfa_session.rb +77 -0
  19. data/lib/generators/authentication/templates/spec/models/otp_credential_spec.rb +215 -0
  20. data/lib/generators/authentication/templates/spec/models/password_reset_token_spec.rb +146 -0
  21. data/lib/generators/authentication/templates/spec/models/session_spec.rb.erb +45 -0
  22. data/lib/generators/authentication/templates/spec/models/tfa_session_spec.rb.erb +115 -0
  23. data/lib/generators/authentication/templates/spec/support/authentication_helpers.rb +18 -0
  24. data/lib/generators/authentication/templates/spec/support/factory_bot.rb +5 -0
  25. data/lib/generators/authentication/templates/spec/system/authentication_spec.rb.erb +25 -0
  26. data/lib/generators/authentication/templates/spec/system/password_resets_spec.rb.erb +73 -0
  27. data/lib/generators/authentication/templates/spec/system/tfa_authentication_spec.rb.erb +38 -0
  28. data/lib/generators/authentication/templates/views/mailers/password_reset_link.html.slim.erb +7 -0
  29. data/lib/generators/authentication/templates/views/password_resets/edit.html.slim.erb +16 -0
  30. data/lib/generators/authentication/templates/views/password_resets/new.html.slim.erb +12 -0
  31. data/lib/generators/authentication/templates/views/sessions/new.html.slim.erb +21 -0
  32. data/lib/generators/authentication/templates/views/tfa_sessions/new.html.slim.erb +26 -0
  33. data/lib/generators/authentication/templates/views/tfas/show.html.slim.erb +9 -0
  34. data/lib/generators/base_controller/USAGE +8 -0
  35. data/lib/generators/base_controller/base_controller_generator.rb +22 -0
  36. data/lib/generators/base_controller/templates/base_controller.rb.erb +7 -0
  37. data/lib/generators/layout_helper/USAGE +8 -0
  38. data/lib/generators/layout_helper/layout_helper_generator.rb +55 -0
  39. data/lib/orthodox/version.rb +1 -1
  40. metadata +39 -2
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal
2
+
3
+ # Form object for authenticating <%= class_name %> records.
4
+ # Automatically generated by the orthodox gem (https://github.com/katanacode/orthodox)
5
+ # (c) Copyright 2019 Katana Code Ltd. All Rights Reserved.
6
+ class <%= class_name %>Session
7
+
8
+ include ActiveModel::Model
9
+ include ActiveModel::Attributes
10
+
11
+ INVALID_CREDENTIALS = "These credentials don't look valid"
12
+
13
+ # ======================================================================================
14
+ # = Attributes =
15
+ # ======================================================================================
16
+
17
+ ##
18
+ # The authenticated <%= class_name %> if authentication is successful
19
+ #
20
+ # Returns ApplicationRecord
21
+ attr_accessor :<%= singular_name %>
22
+
23
+ ##
24
+ # The provided email address String
25
+ #
26
+ # Returns String
27
+ attribute :email, :string
28
+
29
+ ##
30
+ # The provided password String
31
+ #
32
+ # Returns String
33
+ attribute :password, :string
34
+
35
+
36
+ # ======================================================================================
37
+ # = Validations =
38
+ # ======================================================================================
39
+
40
+ validates :email, presence: true, email_format: { allow_blank: true }
41
+
42
+ validates :password, presence: true
43
+
44
+ validate :record_authenticateable
45
+
46
+ validate :password_matches
47
+
48
+
49
+ private
50
+
51
+
52
+ # If the record can be loaded from the scope, sets <%= singular_name %>, otherwise
53
+ # adds errors to :base
54
+ #
55
+ def record_authenticateable
56
+ <%= singular_name %> = <%= singular_name %>_scope.find_by(email: email)
57
+ if <%= singular_name %>.present?
58
+ self.<%= singular_name %> = <%= singular_name %>
59
+ else
60
+ errors.add(:base, INVALID_CREDENTIALS)
61
+ end
62
+ end
63
+
64
+ # If the password isn't correct, add errors to :base
65
+ def password_matches
66
+ return unless <%= singular_name %>
67
+ unless <%= singular_name %>.authenticate(password)
68
+ errors.add(:password, INVALID_CREDENTIALS)
69
+ end
70
+ end
71
+
72
+ # The scope to load <%= plural_class_name %> through. Modify this if there are
73
+ # particular conditions that records should be filtered by.
74
+ #
75
+ # Returns ActiveRecord::Relation
76
+ def <%= singular_name %>_scope
77
+ <%= class_name %>.all
78
+ end
79
+
80
+ end
@@ -0,0 +1,77 @@
1
+ # Form object for handling two-factor authentication sessions
2
+ #
3
+ # Automatically generated by the orthodox gem (https://github.com/katanacode/orthodox)
4
+ # (c) Copyright 2019 Katana Code Ltd. All Rights Reserved.
5
+ class TfaSession
6
+ include ActiveModel::Model
7
+ include ActiveModel::Attributes
8
+
9
+ # =============
10
+ # = Constants =
11
+ # =============
12
+
13
+ ##
14
+ # Regex for OTP format
15
+ OTP_FORMAT = /\A\d{3,5}\Z|/
16
+
17
+ ##
18
+ # Regex for recovery code format
19
+ RECOVERY_CODE_FORMAT = /\A\w{5}\-\w{5}\Z/
20
+
21
+ # ==============
22
+ # = Attributes =
23
+ # ==============
24
+
25
+ attribute :otp, :string
26
+
27
+ attribute :recovery_code, :string
28
+
29
+ attribute :record
30
+
31
+
32
+ # ===============
33
+ # = Validations =
34
+ # ===============
35
+
36
+ validates :otp, format: { with: OTP_FORMAT },
37
+ numericality: { allow_blank: true },
38
+ presence: { unless: :recovery_code? }
39
+
40
+ validates :record, presence: true
41
+
42
+ validate :otp_correct, if: :otp?, unless: :recovery_code?
43
+
44
+ validate :recovery_code_correct, if: :recovery_code?, unless: :otp?
45
+
46
+
47
+ # ===========================
48
+ # = Public instance methods =
49
+ # ===========================
50
+
51
+ def otp?
52
+ otp.present?
53
+ end
54
+
55
+ def recovery_code?
56
+ recovery_code.present?
57
+ end
58
+
59
+ private
60
+
61
+ # ============================
62
+ # = Private instance methods =
63
+ # ============================
64
+
65
+ def otp_correct
66
+ unless record.valid_otp?(otp.to_s)
67
+ errors.add(:base, "OTP code was not correct")
68
+ end
69
+ end
70
+
71
+ def recovery_code_correct
72
+ unless record.valid_recovery_code?(recovery_code.to_s)
73
+ errors.add(:base, "Recovery code was not correct")
74
+ end
75
+ end
76
+
77
+ end
@@ -0,0 +1,215 @@
1
+ # Automatically generated by the orthodox gem (https://github.com/katanacode/orthodox)
2
+ # (c) Copyright 2019 Katana Code Ltd. All Rights Reserved.
3
+ #
4
+ # == Schema Information
5
+ #
6
+ # Table name: otp_credentials
7
+ #
8
+ # id :bigint not null, primary key
9
+ # authable_type :string not null
10
+ # last_used_at :datetime
11
+ # recovery_codes :json
12
+ # secret :string(32)
13
+ # authable_id :bigint not null
14
+ #
15
+ # Indexes
16
+ #
17
+ # index_otp_credentials_on_authable_type_and_authable_id (authable_type,authable_id)
18
+ #
19
+
20
+ require 'rails_helper'
21
+
22
+ RSpec.describe OtpCredential, type: :model do
23
+
24
+ include ActiveSupport::Testing::TimeHelpers
25
+
26
+ describe "on create" do
27
+
28
+ subject do
29
+ record = build(:otp_credential, authable_type: "User", authable_id: 1)
30
+ record.save(validate: false)
31
+ record
32
+ end
33
+
34
+ it "generates a random secret" do
35
+ expect(subject.secret).to be_present
36
+ end
37
+
38
+ it "generates 10 recovery codes" do
39
+ expect(subject.recovery_codes.size).to eql(10)
40
+ end
41
+
42
+ it "sets last_used_at to a past time" do
43
+ expect(subject.last_used_at).to be_past
44
+ end
45
+
46
+ end
47
+
48
+ describe "#url" do
49
+
50
+ let(:otp_credential) do
51
+ record = build(:otp_credential, authable_type: "User", authable_id: 1)
52
+ record.save(validate: false)
53
+ record
54
+ end
55
+
56
+ subject { URI(otp_credential.url) }
57
+
58
+ before do
59
+ test_user = instance_double("TestUser", email: "user-email@example.com")
60
+ allow(otp_credential).to receive(:authable).and_return(test_user)
61
+ end
62
+
63
+ it "contains the otpauth scheme" do
64
+ expect(subject.scheme).to eql("otpauth")
65
+ end
66
+
67
+ it "contains the autheticateable's email" do
68
+ expect(subject.to_s).to include("user-email@example.com")
69
+ end
70
+
71
+ it "contains the application name" do
72
+ expect(subject.to_s).to include(Rails.application.class.module_parent.name)
73
+ end
74
+
75
+ end
76
+
77
+ describe "valid_otp?" do
78
+
79
+ let!(:otp_credential) do
80
+ record = build(:otp_credential, authable_type: "User", authable_id: 1)
81
+ record.save(validate: false)
82
+ record
83
+ end
84
+
85
+ subject { otp_credential.valid_otp?(code) }
86
+
87
+
88
+ context "when code is correct" do
89
+
90
+ let(:code) { otp_credential.send(:current_otp) }
91
+
92
+ it { is_expected.to be_truthy }
93
+
94
+ end
95
+
96
+ context "when code is correct but already used" do
97
+
98
+ let(:code) { otp_credential.send(:current_otp) }
99
+
100
+ before do
101
+ otp_credential.valid_otp?(code)
102
+ end
103
+
104
+ it { is_expected.to be_falsey }
105
+
106
+ end
107
+
108
+ context "when code is correct and expired less than DRIFT_ALLOWANCE" do
109
+
110
+ let!(:code) { otp_credential.send(:current_otp) }
111
+
112
+ it "is exepected to be truthy" do
113
+
114
+ time_to_travel = seconds_until_next_otp + 14
115
+ travel(time_to_travel.seconds) {
116
+ expect(otp_credential).to be_valid_otp(code)
117
+ }
118
+ end
119
+
120
+ end
121
+
122
+ context "when code is correct but expired" do
123
+
124
+ let!(:code) { otp_credential.send(:current_otp) }
125
+
126
+ it "is exepected to be truthy" do
127
+ time_to_travel = seconds_until_next_otp + 15
128
+ travel(time_to_travel.seconds) {
129
+ expect(subject).to be_falsey
130
+ }
131
+ end
132
+
133
+ end
134
+
135
+ context "when otp is incorrect" do
136
+
137
+ let(:code) { "99999" }
138
+
139
+ it { is_expected.to be_falsey }
140
+
141
+ end
142
+
143
+ end
144
+
145
+ describe "valid_recovery_code?" do
146
+
147
+ let!(:otp_credential) do
148
+ record = build(:otp_credential, authable_type: "User", authable_id: 1)
149
+ record.save(validate: false)
150
+ record
151
+ end
152
+
153
+ context "when recovery_code is in the list" do
154
+
155
+ it "is expected to return true" do
156
+ recovery_code = otp_credential.recovery_codes.sample
157
+ expect(otp_credential.valid_recovery_code?(recovery_code)).to be_truthy
158
+ end
159
+
160
+ end
161
+
162
+ context "when recovery_code is not in the list" do
163
+
164
+ it "is expected to return false" do
165
+ recovery_code = "not-in-list"
166
+ expect(otp_credential.valid_recovery_code?(recovery_code)).to be_falsey
167
+ end
168
+
169
+ end
170
+
171
+ end
172
+
173
+ describe "consume_recovery_code!" do
174
+
175
+ let(:otp_credential) do
176
+ record = build(:otp_credential, authable_type: "User", authable_id: 1)
177
+ record.save(validate: false)
178
+ record
179
+ end
180
+
181
+
182
+
183
+ context "when recovery_code is in the list" do
184
+
185
+ it "removes it from the list" do
186
+ recovery_code = otp_credential.recovery_codes.sample
187
+ expect(otp_credential.valid_recovery_code?(recovery_code)).to eql(true)
188
+ otp_credential.consume_recovery_code!(recovery_code)
189
+ expect(otp_credential.valid_recovery_code?(recovery_code)).to eql(false)
190
+ end
191
+
192
+ end
193
+
194
+ context "when recovery_code is not in the list" do
195
+
196
+ it "fails silently" do
197
+ recovery_code = "not-in-list"
198
+ expect(otp_credential.valid_recovery_code?(recovery_code)).to eql(false)
199
+ otp_credential.consume_recovery_code!(recovery_code)
200
+ expect(otp_credential.valid_recovery_code?(recovery_code)).to eql(false)
201
+ end
202
+
203
+ end
204
+
205
+ end
206
+
207
+ private
208
+
209
+ def seconds_until_next_otp
210
+ change_window = 30 # seconds
211
+ diff_since_last_change = Time.now.sec % change_window
212
+ change_window - diff_since_last_change
213
+ end
214
+
215
+ end
@@ -0,0 +1,146 @@
1
+ # Automatically generated by the orthodox gem (https://github.com/katanacode/orthodox)
2
+ # (c) Copyright 2019 Katana Code Ltd. All Rights Reserved.
3
+ #
4
+ # == Schema Information
5
+ #
6
+ # Table name: password_reset_tokens
7
+ #
8
+ # id :bigint not null, primary key
9
+ # expires_at :datetime
10
+ # resetable_type :string not null
11
+ # secret :string
12
+ # created_at :datetime not null
13
+ # updated_at :datetime not null
14
+ # resetable_id :bigint not null
15
+ #
16
+ # Indexes
17
+ #
18
+ # index_password_reset_tokens_on_expires_at (expires_at)
19
+ # index_password_reset_tokens_on_resetable_type_and_resetable_id (resetable_type,resetable_id)
20
+ # index_password_reset_tokens_on_secret (secret) UNIQUE
21
+ #
22
+
23
+ require 'rails_helper'
24
+
25
+ RSpec.describe PasswordResetToken, type: :model do
26
+
27
+ include ActiveSupport::Testing::TimeHelpers
28
+
29
+ describe "#secret" do
30
+
31
+ let(:password_reset_token) do
32
+ token = PasswordResetToken.new(resetable_id: 1, resetable_type: "User")
33
+ token.save(validate: false)
34
+ token
35
+ end
36
+
37
+ it "is set on creation" do
38
+ expect(password_reset_token.secret).to be_present
39
+ end
40
+
41
+ it "is readonly" do
42
+ original_secret = password_reset_token.secret
43
+ password_reset_token.secret = "somenewvalue"
44
+ password_reset_token.save(validate: false)
45
+ password_reset_token.reload
46
+ expect(password_reset_token.secret).to eql(original_secret)
47
+ end
48
+
49
+ end
50
+
51
+ describe "#expires_at" do
52
+
53
+ let(:password_reset_token) do
54
+ token = PasswordResetToken.new(resetable_id: 1, resetable_type: "User")
55
+ token.save(validate: false)
56
+ token
57
+ end
58
+
59
+ it "is set on creation" do
60
+ expect(password_reset_token.expires_at).to be_present
61
+ end
62
+
63
+ it "is readonly" do
64
+ original_expires_at = password_reset_token.expires_at
65
+ password_reset_token.expires_at = 1.day.from_now
66
+ password_reset_token.save(validate: false)
67
+ password_reset_token.reload
68
+ expect(password_reset_token.expires_at).to eql(original_expires_at)
69
+ end
70
+
71
+ end
72
+
73
+ describe "#resetable_id" do
74
+
75
+ let(:password_reset_token) do
76
+ token = PasswordResetToken.new(resetable_id: 1, resetable_type: "User")
77
+ token.save(validate: false)
78
+ token
79
+ end
80
+
81
+ it "is set on creation" do
82
+ expect(password_reset_token.resetable_id).to be_present
83
+ end
84
+
85
+ it "is readonly" do
86
+ original_resetable_id = password_reset_token.resetable_id
87
+ password_reset_token.resetable_id = 1.day.from_now
88
+ password_reset_token.save(validate: false)
89
+ password_reset_token.reload
90
+ expect(password_reset_token.resetable_id).to eql(original_resetable_id)
91
+ end
92
+
93
+ end
94
+
95
+ describe "#resetable_type" do
96
+
97
+ let(:password_reset_token) do
98
+ token = PasswordResetToken.new(resetable_id: 1, resetable_type: "User")
99
+ token.save(validate: false)
100
+ token
101
+ end
102
+
103
+ it "is set on creation" do
104
+ expect(password_reset_token.resetable_type).to be_present
105
+ end
106
+
107
+ it "is readonly" do
108
+ original_resetable_type = password_reset_token.resetable_type
109
+ password_reset_token.resetable_type = 1.day.from_now
110
+ password_reset_token.save(validate: false)
111
+ password_reset_token.reload
112
+ expect(password_reset_token.resetable_type).to eql(original_resetable_type)
113
+ end
114
+
115
+ end
116
+
117
+ describe "#expired?" do
118
+
119
+ let(:password_reset_token) do
120
+ token = PasswordResetToken.new(resetable_id: 1, resetable_type: "User")
121
+ token.save(validate: false)
122
+ token
123
+ end
124
+
125
+ context "when still within time threshhold" do
126
+
127
+ it "is not expired" do
128
+ expect(password_reset_token).not_to be_expired
129
+ end
130
+
131
+ end
132
+
133
+ context "when outwith time threshhold" do
134
+
135
+ it "is expected to be false" do
136
+ password_reset_token
137
+ travel(16.minutes) do
138
+ expect(password_reset_token).to be_expired
139
+ end
140
+ end
141
+
142
+ end
143
+
144
+ end
145
+
146
+ end