orthodox 0.2.4 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
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