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.
- checksums.yaml +4 -4
- data/lib/generators/authentication/USAGE +14 -0
- data/lib/generators/authentication/authentication_generator.rb +214 -0
- data/lib/generators/authentication/templates/controllers/concerns/authentication.rb.erb +110 -0
- data/lib/generators/authentication/templates/controllers/concerns/two_factor_authentication.rb +40 -0
- data/lib/generators/authentication/templates/controllers/password_resets_controller.rb.erb +54 -0
- data/lib/generators/authentication/templates/controllers/sessions_controller.rb.erb +36 -0
- data/lib/generators/authentication/templates/controllers/tfa_sessions_controller.rb.erb +48 -0
- data/lib/generators/authentication/templates/controllers/tfas_controller.rb.erb +38 -0
- data/lib/generators/authentication/templates/helpers/otp_credentials_helper.rb +33 -0
- data/lib/generators/authentication/templates/javascript/tfa_forms.js +19 -0
- data/lib/generators/authentication/templates/models/concerns/authenticateable.rb +37 -0
- data/lib/generators/authentication/templates/models/concerns/otpable.rb +26 -0
- data/lib/generators/authentication/templates/models/concerns/password_resetable.rb +19 -0
- data/lib/generators/authentication/templates/models/otp_credential.rb.erb +133 -0
- data/lib/generators/authentication/templates/models/password_reset_token.rb +64 -0
- data/lib/generators/authentication/templates/models/session.rb.erb +80 -0
- data/lib/generators/authentication/templates/models/tfa_session.rb +77 -0
- data/lib/generators/authentication/templates/spec/models/otp_credential_spec.rb +215 -0
- data/lib/generators/authentication/templates/spec/models/password_reset_token_spec.rb +146 -0
- data/lib/generators/authentication/templates/spec/models/session_spec.rb.erb +45 -0
- data/lib/generators/authentication/templates/spec/models/tfa_session_spec.rb.erb +115 -0
- data/lib/generators/authentication/templates/spec/support/authentication_helpers.rb +18 -0
- data/lib/generators/authentication/templates/spec/support/factory_bot.rb +5 -0
- data/lib/generators/authentication/templates/spec/system/authentication_spec.rb.erb +25 -0
- data/lib/generators/authentication/templates/spec/system/password_resets_spec.rb.erb +73 -0
- data/lib/generators/authentication/templates/spec/system/tfa_authentication_spec.rb.erb +38 -0
- data/lib/generators/authentication/templates/views/mailers/password_reset_link.html.slim.erb +7 -0
- data/lib/generators/authentication/templates/views/password_resets/edit.html.slim.erb +16 -0
- data/lib/generators/authentication/templates/views/password_resets/new.html.slim.erb +12 -0
- data/lib/generators/authentication/templates/views/sessions/new.html.slim.erb +21 -0
- data/lib/generators/authentication/templates/views/tfa_sessions/new.html.slim.erb +26 -0
- data/lib/generators/authentication/templates/views/tfas/show.html.slim.erb +9 -0
- data/lib/generators/base_controller/USAGE +8 -0
- data/lib/generators/base_controller/base_controller_generator.rb +22 -0
- data/lib/generators/base_controller/templates/base_controller.rb.erb +7 -0
- data/lib/generators/layout_helper/USAGE +8 -0
- data/lib/generators/layout_helper/layout_helper_generator.rb +55 -0
- data/lib/orthodox/version.rb +1 -1
- 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
|