custos 0.1.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 +7 -0
- data/CHANGELOG.md +36 -0
- data/LICENSE.txt +21 -0
- data/README.md +117 -0
- data/lib/custos/authenticatable.rb +30 -0
- data/lib/custos/callback_registry.rb +37 -0
- data/lib/custos/configuration.rb +35 -0
- data/lib/custos/controller_helpers.rb +47 -0
- data/lib/custos/mfa_encryptor.rb +63 -0
- data/lib/custos/model_config.rb +36 -0
- data/lib/custos/models/api_token.rb +23 -0
- data/lib/custos/models/magic_link_token.rb +13 -0
- data/lib/custos/models/mfa_credential.rb +22 -0
- data/lib/custos/models/remember_token.rb +11 -0
- data/lib/custos/plugin.rb +30 -0
- data/lib/custos/plugins/api_tokens.rb +48 -0
- data/lib/custos/plugins/email_confirmation.rb +59 -0
- data/lib/custos/plugins/lockout.rb +116 -0
- data/lib/custos/plugins/magic_link.rb +60 -0
- data/lib/custos/plugins/mfa.rb +185 -0
- data/lib/custos/plugins/password.rb +98 -0
- data/lib/custos/plugins/remember_me.rb +56 -0
- data/lib/custos/railtie.rb +15 -0
- data/lib/custos/session.rb +16 -0
- data/lib/custos/session_manager.rb +51 -0
- data/lib/custos/tasks/cleanup.rake +28 -0
- data/lib/custos/token_generator.rb +37 -0
- data/lib/custos/version.rb +5 -0
- data/lib/custos.rb +57 -0
- data/lib/generators/custos/install/install_generator.rb +23 -0
- data/lib/generators/custos/install/templates/create_custos_sessions.rb.tt +19 -0
- data/lib/generators/custos/install/templates/custos_initializer.rb.tt +12 -0
- data/lib/generators/custos/model/model_generator.rb +89 -0
- data/lib/generators/custos/model/templates/add_custos_columns.rb.tt +18 -0
- data/lib/generators/custos/model/templates/create_custos_api_tokens.rb.tt +18 -0
- data/lib/generators/custos/model/templates/create_custos_magic_links.rb.tt +17 -0
- data/lib/generators/custos/model/templates/create_custos_mfa_credentials.rb.tt +16 -0
- data/lib/generators/custos/model/templates/create_custos_remember_tokens.rb.tt +16 -0
- metadata +129 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Custos
|
|
4
|
+
module Plugins
|
|
5
|
+
module Lockout
|
|
6
|
+
DEFAULT_MAX_ATTEMPTS = 3
|
|
7
|
+
DEFAULT_LOCKOUT_DURATION = 30 * 60 # 30 minutes in seconds
|
|
8
|
+
DEFAULT_MAX_MFA_ATTEMPTS = 5
|
|
9
|
+
|
|
10
|
+
def self.apply(model_class, **_options)
|
|
11
|
+
model_class.include(InstanceMethods)
|
|
12
|
+
|
|
13
|
+
model_class.custos_config.hook(:after_authentication) do |record, success|
|
|
14
|
+
if success
|
|
15
|
+
record.reset_failed_attempts!
|
|
16
|
+
else
|
|
17
|
+
record.record_failed_attempt!
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
model_class.custos_config.hook(:after_mfa_verification) do |record, success|
|
|
22
|
+
if success
|
|
23
|
+
record.reset_failed_mfa_attempts!
|
|
24
|
+
else
|
|
25
|
+
record.record_failed_mfa_attempt!
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
module InstanceMethods
|
|
31
|
+
def locked?
|
|
32
|
+
return false if locked_at.nil?
|
|
33
|
+
|
|
34
|
+
options = self.class.custos_config.plugin_options(:lockout)
|
|
35
|
+
duration = options.fetch(:lockout_duration, DEFAULT_LOCKOUT_DURATION)
|
|
36
|
+
locked_at > duration.seconds.ago
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def mfa_locked?
|
|
40
|
+
return false unless respond_to?(:mfa_locked_at)
|
|
41
|
+
return false if mfa_locked_at.nil?
|
|
42
|
+
|
|
43
|
+
options = self.class.custos_config.plugin_options(:lockout)
|
|
44
|
+
duration = options.fetch(:mfa_lockout_duration,
|
|
45
|
+
options.fetch(:lockout_duration, DEFAULT_LOCKOUT_DURATION))
|
|
46
|
+
mfa_locked_at > duration.seconds.ago
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def record_failed_attempt!
|
|
50
|
+
options = self.class.custos_config.plugin_options(:lockout)
|
|
51
|
+
max = options.fetch(:max_attempts, DEFAULT_MAX_ATTEMPTS)
|
|
52
|
+
duration = options.fetch(:lockout_duration, DEFAULT_LOCKOUT_DURATION)
|
|
53
|
+
now = Time.current
|
|
54
|
+
|
|
55
|
+
self.class.where(id: id).update_all(
|
|
56
|
+
[
|
|
57
|
+
'failed_auth_count = CASE WHEN locked_at IS NOT NULL AND locked_at <= ? THEN 1 ' \
|
|
58
|
+
'ELSE failed_auth_count + 1 END, ' \
|
|
59
|
+
'locked_at = CASE WHEN locked_at IS NOT NULL AND locked_at <= ? THEN NULL ' \
|
|
60
|
+
'WHEN (CASE WHEN locked_at IS NOT NULL AND locked_at <= ? THEN 1 ' \
|
|
61
|
+
'ELSE failed_auth_count + 1 END) >= ? THEN ? ELSE locked_at END',
|
|
62
|
+
duration.seconds.ago, duration.seconds.ago, duration.seconds.ago, max, now
|
|
63
|
+
]
|
|
64
|
+
)
|
|
65
|
+
reload
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def record_failed_mfa_attempt!
|
|
69
|
+
return unless respond_to?(:failed_mfa_count)
|
|
70
|
+
|
|
71
|
+
options = self.class.custos_config.plugin_options(:lockout)
|
|
72
|
+
max = options.fetch(:max_mfa_attempts, DEFAULT_MAX_MFA_ATTEMPTS)
|
|
73
|
+
duration = options.fetch(:mfa_lockout_duration,
|
|
74
|
+
options.fetch(:lockout_duration, DEFAULT_LOCKOUT_DURATION))
|
|
75
|
+
now = Time.current
|
|
76
|
+
|
|
77
|
+
self.class.where(id: id).update_all(
|
|
78
|
+
[
|
|
79
|
+
'failed_mfa_count = CASE WHEN mfa_locked_at IS NOT NULL AND mfa_locked_at <= ? THEN 1 ' \
|
|
80
|
+
'ELSE failed_mfa_count + 1 END, ' \
|
|
81
|
+
'mfa_locked_at = CASE WHEN mfa_locked_at IS NOT NULL AND mfa_locked_at <= ? THEN NULL ' \
|
|
82
|
+
'WHEN (CASE WHEN mfa_locked_at IS NOT NULL AND mfa_locked_at <= ? THEN 1 ' \
|
|
83
|
+
'ELSE failed_mfa_count + 1 END) >= ? THEN ? ELSE mfa_locked_at END',
|
|
84
|
+
duration.seconds.ago, duration.seconds.ago, duration.seconds.ago, max, now
|
|
85
|
+
]
|
|
86
|
+
)
|
|
87
|
+
reload
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def reset_failed_attempts!
|
|
91
|
+
return unless failed_auth_count.positive? || locked_at.present?
|
|
92
|
+
|
|
93
|
+
update!(failed_auth_count: 0, locked_at: nil)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def reset_failed_mfa_attempts!
|
|
97
|
+
return unless respond_to?(:failed_mfa_count)
|
|
98
|
+
return unless failed_mfa_count.positive? || mfa_locked_at.present?
|
|
99
|
+
|
|
100
|
+
update!(failed_mfa_count: 0, mfa_locked_at: nil)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def unlock!
|
|
104
|
+
attrs = { failed_auth_count: 0, locked_at: nil }
|
|
105
|
+
if respond_to?(:failed_mfa_count)
|
|
106
|
+
attrs[:failed_mfa_count] = 0
|
|
107
|
+
attrs[:mfa_locked_at] = nil
|
|
108
|
+
end
|
|
109
|
+
update!(attrs)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
Custos::Plugin.register(:lockout, Custos::Plugins::Lockout)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Custos
|
|
4
|
+
module Plugins
|
|
5
|
+
module MagicLink
|
|
6
|
+
DEFAULT_EXPIRY = 15 * 60 # 15 minutes in seconds
|
|
7
|
+
DEFAULT_COOLDOWN = 60 # 1 minute between requests
|
|
8
|
+
|
|
9
|
+
def self.apply(model_class, **_options)
|
|
10
|
+
model_class.has_many :custos_magic_link_tokens,
|
|
11
|
+
class_name: 'Custos::MagicLinkToken',
|
|
12
|
+
as: :authenticatable,
|
|
13
|
+
dependent: :destroy
|
|
14
|
+
|
|
15
|
+
model_class.extend(ClassMethods)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
module ClassMethods
|
|
19
|
+
def generate_magic_link(email)
|
|
20
|
+
record = find_by(email: email)
|
|
21
|
+
return nil unless record
|
|
22
|
+
|
|
23
|
+
options = custos_config.plugin_options(:magic_link)
|
|
24
|
+
cooldown = options.fetch(:cooldown, DEFAULT_COOLDOWN)
|
|
25
|
+
if cooldown.positive?
|
|
26
|
+
last_token = record.custos_magic_link_tokens.order(created_at: :desc).first
|
|
27
|
+
return nil if last_token && last_token.created_at > cooldown.seconds.ago
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
record.custos_magic_link_tokens.valid_tokens.update_all(used_at: Time.current)
|
|
31
|
+
|
|
32
|
+
token = Custos::TokenGenerator.generate
|
|
33
|
+
expiry = options.fetch(:expiry, DEFAULT_EXPIRY)
|
|
34
|
+
|
|
35
|
+
record.custos_magic_link_tokens.create!(
|
|
36
|
+
token_digest: Custos::TokenGenerator.digest(token),
|
|
37
|
+
expires_at: Time.current + expiry
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
Custos::CallbackRegistry.fire(self, :magic_link_created, record, token)
|
|
41
|
+
token
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def authenticate_magic_link(token)
|
|
45
|
+
digest = Custos::TokenGenerator.digest(token)
|
|
46
|
+
magic_link = Custos::MagicLinkToken.valid_tokens.find_by(
|
|
47
|
+
token_digest: digest,
|
|
48
|
+
authenticatable_type: name
|
|
49
|
+
)
|
|
50
|
+
return nil unless magic_link
|
|
51
|
+
|
|
52
|
+
magic_link.update!(used_at: Time.current)
|
|
53
|
+
magic_link.authenticatable
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
Custos::Plugin.register(:magic_link, Custos::Plugins::MagicLink)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rotp'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'time'
|
|
6
|
+
|
|
7
|
+
module Custos
|
|
8
|
+
module Plugins
|
|
9
|
+
module Mfa
|
|
10
|
+
SMS_CODE_LENGTH = 6
|
|
11
|
+
SMS_CODE_EXPIRY = 5 * 60 # 5 minutes
|
|
12
|
+
BACKUP_CODES_COUNT = 10
|
|
13
|
+
|
|
14
|
+
def self.apply(model_class, **_options)
|
|
15
|
+
model_class.has_many :custos_mfa_credentials,
|
|
16
|
+
class_name: 'Custos::MfaCredential',
|
|
17
|
+
as: :authenticatable,
|
|
18
|
+
dependent: :destroy
|
|
19
|
+
|
|
20
|
+
model_class.include(InstanceMethods)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
module InstanceMethods
|
|
24
|
+
def setup_totp(issuer: 'Custos')
|
|
25
|
+
secret = ROTP::Base32.random
|
|
26
|
+
credential = find_or_build_mfa_credential('totp')
|
|
27
|
+
credential.update!(secret_data: secret, enabled_at: nil)
|
|
28
|
+
|
|
29
|
+
totp = ROTP::TOTP.new(secret, issuer: issuer)
|
|
30
|
+
totp.provisioning_uri(respond_to?(:email) ? email : id.to_s)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def verify_totp(code)
|
|
34
|
+
return false if respond_to?(:mfa_locked?) && mfa_locked?
|
|
35
|
+
|
|
36
|
+
credential = enabled_mfa_credential('totp')
|
|
37
|
+
return false unless credential
|
|
38
|
+
|
|
39
|
+
secret = credential.secret_data
|
|
40
|
+
return false unless secret
|
|
41
|
+
|
|
42
|
+
totp = ROTP::TOTP.new(secret)
|
|
43
|
+
result = totp.verify(code.to_s, drift_behind: 30, drift_ahead: 30).present?
|
|
44
|
+
fire_mfa_verification_hook(result)
|
|
45
|
+
result
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def confirm_totp!(code)
|
|
49
|
+
credential = custos_mfa_credentials.by_method('totp').first
|
|
50
|
+
return false unless credential
|
|
51
|
+
|
|
52
|
+
secret = credential.secret_data
|
|
53
|
+
return false unless secret
|
|
54
|
+
|
|
55
|
+
totp = ROTP::TOTP.new(secret)
|
|
56
|
+
return false unless totp.verify(code.to_s, drift_behind: 30, drift_ahead: 30)
|
|
57
|
+
|
|
58
|
+
credential.update!(enabled_at: Time.current)
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def totp_enabled?
|
|
63
|
+
enabled_mfa_credential('totp').present?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def generate_backup_codes(count: BACKUP_CODES_COUNT)
|
|
67
|
+
codes = Array.new(count) { SecureRandom.hex(6) }
|
|
68
|
+
digests = codes.map { |code| Custos::TokenGenerator.digest(code) }
|
|
69
|
+
|
|
70
|
+
credential = find_or_build_mfa_credential('backup_codes')
|
|
71
|
+
credential.update!(secret_data: JSON.generate(digests), enabled_at: Time.current)
|
|
72
|
+
|
|
73
|
+
codes
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def verify_backup_code(code)
|
|
77
|
+
return false if respond_to?(:mfa_locked?) && mfa_locked?
|
|
78
|
+
|
|
79
|
+
credential = enabled_mfa_credential('backup_codes')
|
|
80
|
+
return false unless credential
|
|
81
|
+
|
|
82
|
+
result = consume_backup_code(credential, code)
|
|
83
|
+
fire_mfa_verification_hook(result)
|
|
84
|
+
result
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def send_sms_code
|
|
88
|
+
code = SecureRandom.random_number(10**SMS_CODE_LENGTH).to_s.rjust(SMS_CODE_LENGTH, '0')
|
|
89
|
+
digest = Custos::TokenGenerator.digest(code)
|
|
90
|
+
expiry = Time.current + SMS_CODE_EXPIRY
|
|
91
|
+
|
|
92
|
+
credential = find_or_build_mfa_credential('sms')
|
|
93
|
+
credential.update!(
|
|
94
|
+
secret_data: JSON.generate({ digest: digest, expires_at: expiry.iso8601 }),
|
|
95
|
+
enabled_at: Time.current
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
Custos::CallbackRegistry.fire(self.class, :sms_code_created, self, code)
|
|
99
|
+
true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def verify_sms_code(code)
|
|
103
|
+
return false if respond_to?(:mfa_locked?) && mfa_locked?
|
|
104
|
+
|
|
105
|
+
credential = enabled_mfa_credential('sms')
|
|
106
|
+
return false unless credential
|
|
107
|
+
|
|
108
|
+
raw = credential.secret_data
|
|
109
|
+
return false unless raw
|
|
110
|
+
|
|
111
|
+
data = parse_json_hash(raw)
|
|
112
|
+
return false if data.empty?
|
|
113
|
+
return false if Time.iso8601(data['expires_at']) < Time.current
|
|
114
|
+
|
|
115
|
+
result = Custos::TokenGenerator.secure_compare(
|
|
116
|
+
data['digest'],
|
|
117
|
+
Custos::TokenGenerator.digest(code)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
invalidate_sms_credential(credential) if result
|
|
121
|
+
fire_mfa_verification_hook(result)
|
|
122
|
+
result
|
|
123
|
+
rescue ArgumentError
|
|
124
|
+
false
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def mfa_enabled?
|
|
128
|
+
totp_enabled? || enabled_mfa_credential('sms').present?
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def fire_mfa_verification_hook(success)
|
|
134
|
+
Custos::CallbackRegistry.fire_hooks(self.class, :after_mfa_verification, self, success)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def consume_backup_code(credential, code)
|
|
138
|
+
credential.with_lock do
|
|
139
|
+
digest = Custos::TokenGenerator.digest(code)
|
|
140
|
+
raw = credential.secret_data
|
|
141
|
+
return false unless raw
|
|
142
|
+
|
|
143
|
+
remaining = parse_json_array(raw)
|
|
144
|
+
matched_index = remaining.index { |stored| Custos::TokenGenerator.secure_compare(stored, digest) }
|
|
145
|
+
return false unless matched_index
|
|
146
|
+
|
|
147
|
+
remaining.delete_at(matched_index)
|
|
148
|
+
credential.update!(secret_data: JSON.generate(remaining))
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
true
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def find_or_build_mfa_credential(method_name)
|
|
155
|
+
custos_mfa_credentials.by_method(method_name).first ||
|
|
156
|
+
custos_mfa_credentials.build(method: method_name)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def enabled_mfa_credential(method_name)
|
|
160
|
+
custos_mfa_credentials.enabled.by_method(method_name).first
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def invalidate_sms_credential(credential)
|
|
164
|
+
credential.update!(secret_data: JSON.generate({ digest: '', expires_at: Time.current.iso8601 }))
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def parse_json_array(json_string)
|
|
168
|
+
parsed = JSON.parse(json_string)
|
|
169
|
+
parsed.is_a?(Array) ? parsed : []
|
|
170
|
+
rescue JSON::ParserError
|
|
171
|
+
[]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def parse_json_hash(json_string)
|
|
175
|
+
parsed = JSON.parse(json_string)
|
|
176
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
177
|
+
rescue JSON::ParserError
|
|
178
|
+
{}
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
Custos::Plugin.register(:mfa, Custos::Plugins::Mfa)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'argon2'
|
|
4
|
+
|
|
5
|
+
module Custos
|
|
6
|
+
module Plugins
|
|
7
|
+
module Password
|
|
8
|
+
DEFAULT_MIN_LENGTH = 8
|
|
9
|
+
DEFAULT_MAX_LENGTH = 128
|
|
10
|
+
DUMMY_PASSWORD_DIGEST = Argon2::Password.create('custos-timing-protection')
|
|
11
|
+
|
|
12
|
+
def self.apply(model_class, **options)
|
|
13
|
+
model_class.include(InstanceMethods)
|
|
14
|
+
model_class.extend(ClassMethods)
|
|
15
|
+
|
|
16
|
+
min = options.fetch(:min_length, DEFAULT_MIN_LENGTH)
|
|
17
|
+
max = options.fetch(:max_length, DEFAULT_MAX_LENGTH)
|
|
18
|
+
model_class.validates :password, length: { minimum: min, maximum: max }, if: :password_changed?
|
|
19
|
+
|
|
20
|
+
if options[:require_uppercase]
|
|
21
|
+
model_class.validates :password, format: { with: /[A-Z]/, message: 'must contain an uppercase letter' },
|
|
22
|
+
if: :password_changed?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
if options[:require_digit]
|
|
26
|
+
model_class.validates :password, format: { with: /\d/, message: 'must contain a digit' },
|
|
27
|
+
if: :password_changed?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
if options[:require_special]
|
|
31
|
+
model_class.validates :password,
|
|
32
|
+
format: { with: /[^A-Za-z0-9]/, message: 'must contain a special character' },
|
|
33
|
+
if: :password_changed?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
model_class.after_save :clear_password_instance_variable, if: :password_changed?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
module InstanceMethods
|
|
40
|
+
attr_reader :password
|
|
41
|
+
|
|
42
|
+
def password=(plain_password)
|
|
43
|
+
@password = plain_password
|
|
44
|
+
@password_changed = true
|
|
45
|
+
self.password_digest = plain_password.present? ? create_argon2_hash(plain_password) : nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def authenticate_password(plain_password)
|
|
49
|
+
return false if password_digest.blank?
|
|
50
|
+
|
|
51
|
+
verified = Argon2::Password.verify_password(plain_password, password_digest)
|
|
52
|
+
Custos::CallbackRegistry.fire_hooks(self.class, :after_authentication, self, verified)
|
|
53
|
+
verified
|
|
54
|
+
rescue Argon2::ArgonHashFail
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def password_changed?
|
|
61
|
+
@password_changed == true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def clear_password_instance_variable
|
|
65
|
+
@password = nil
|
|
66
|
+
@password_changed = false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def create_argon2_hash(plain_password)
|
|
70
|
+
options = self.class.custos_config.plugin_options(:password)
|
|
71
|
+
argon2_params = {}
|
|
72
|
+
argon2_params[:t_cost] = options[:t_cost] if options.key?(:t_cost)
|
|
73
|
+
argon2_params[:m_cost] = options[:m_cost] if options.key?(:m_cost)
|
|
74
|
+
argon2_params[:p_cost] = options[:p_cost] if options.key?(:p_cost)
|
|
75
|
+
|
|
76
|
+
Argon2::Password.create(plain_password, **argon2_params)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
module ClassMethods
|
|
81
|
+
def find_by_email_and_password(email:, password:)
|
|
82
|
+
record = find_by(email: email)
|
|
83
|
+
|
|
84
|
+
unless record
|
|
85
|
+
Argon2::Password.verify_password(password, DUMMY_PASSWORD_DIGEST)
|
|
86
|
+
return nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
return nil if record.respond_to?(:locked?) && record.locked?
|
|
90
|
+
|
|
91
|
+
record.authenticate_password(password) ? record : nil
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
Custos::Plugin.register(:password, Custos::Plugins::Password)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Custos
|
|
4
|
+
module Plugins
|
|
5
|
+
module RememberMe
|
|
6
|
+
DEFAULT_REMEMBER_DURATION = 30 * 24 * 60 * 60 # 30 days in seconds
|
|
7
|
+
|
|
8
|
+
def self.apply(model_class, **_options)
|
|
9
|
+
model_class.has_many :custos_remember_tokens,
|
|
10
|
+
class_name: 'Custos::RememberToken',
|
|
11
|
+
as: :authenticatable,
|
|
12
|
+
dependent: :destroy
|
|
13
|
+
|
|
14
|
+
model_class.include(InstanceMethods)
|
|
15
|
+
model_class.extend(ClassMethods)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
module InstanceMethods
|
|
19
|
+
def generate_remember_token
|
|
20
|
+
options = self.class.custos_config.plugin_options(:remember_me)
|
|
21
|
+
duration = options.fetch(:remember_duration, DEFAULT_REMEMBER_DURATION)
|
|
22
|
+
token = Custos::TokenGenerator.generate
|
|
23
|
+
|
|
24
|
+
custos_remember_tokens.create!(
|
|
25
|
+
token_digest: Custos::TokenGenerator.digest(token),
|
|
26
|
+
expires_at: Time.current + duration
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
token
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def forget_me!(token = nil)
|
|
33
|
+
if token
|
|
34
|
+
digest = Custos::TokenGenerator.digest(token)
|
|
35
|
+
custos_remember_tokens.where(token_digest: digest).destroy_all
|
|
36
|
+
else
|
|
37
|
+
custos_remember_tokens.destroy_all
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
module ClassMethods
|
|
43
|
+
def authenticate_remember_token(token)
|
|
44
|
+
digest = Custos::TokenGenerator.digest(token)
|
|
45
|
+
remember = Custos::RememberToken.not_expired.find_by(
|
|
46
|
+
token_digest: digest,
|
|
47
|
+
authenticatable_type: name
|
|
48
|
+
)
|
|
49
|
+
remember&.authenticatable
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
Custos::Plugin.register(:remember_me, Custos::Plugins::RememberMe)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Custos
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
initializer 'custos.controller_helpers' do
|
|
6
|
+
ActiveSupport.on_load(:action_controller) do
|
|
7
|
+
include Custos::ControllerHelpers
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
rake_tasks do
|
|
12
|
+
load 'custos/tasks/cleanup.rake'
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Custos
|
|
4
|
+
class Session < ActiveRecord::Base
|
|
5
|
+
self.table_name = 'custos_sessions'
|
|
6
|
+
|
|
7
|
+
belongs_to :authenticatable, polymorphic: true
|
|
8
|
+
|
|
9
|
+
scope :active, lambda {
|
|
10
|
+
where(revoked_at: nil)
|
|
11
|
+
.where('last_active_at > ?', Custos.configuration.session_expiry.seconds.ago)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
scope :revoked, -> { where.not(revoked_at: nil) }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Custos
|
|
4
|
+
class SessionManager
|
|
5
|
+
class << self
|
|
6
|
+
def create(authenticatable, request:)
|
|
7
|
+
token = TokenGenerator.generate
|
|
8
|
+
session = Custos::Session.create!(
|
|
9
|
+
authenticatable: authenticatable,
|
|
10
|
+
session_token_digest: TokenGenerator.digest(token),
|
|
11
|
+
ip_address: request.remote_ip,
|
|
12
|
+
user_agent: request.user_agent,
|
|
13
|
+
last_active_at: Time.current
|
|
14
|
+
)
|
|
15
|
+
[session, token]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def find_by_token(token, authenticatable_type: nil)
|
|
19
|
+
return nil if token.blank?
|
|
20
|
+
|
|
21
|
+
digest = TokenGenerator.digest(token)
|
|
22
|
+
scope = Custos::Session.active.where(session_token_digest: digest)
|
|
23
|
+
scope = scope.where(authenticatable_type: authenticatable_type) if authenticatable_type
|
|
24
|
+
session = scope.first
|
|
25
|
+
touch_session(session) if session
|
|
26
|
+
session
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def revoke(session)
|
|
30
|
+
session.update!(revoked_at: Time.current)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def revoke_all(authenticatable)
|
|
34
|
+
authenticatable.custos_sessions.active.update_all(revoked_at: Time.current)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def active_for(authenticatable)
|
|
38
|
+
authenticatable.custos_sessions.active.order(last_active_at: :desc)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def touch_session(session)
|
|
44
|
+
renewal = Custos.configuration.session_renewal_interval
|
|
45
|
+
return if session.last_active_at > renewal.seconds.ago
|
|
46
|
+
|
|
47
|
+
session.update_column(:last_active_at, Time.current)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :custos do
|
|
4
|
+
desc 'Remove expired sessions, used magic links, and expired tokens'
|
|
5
|
+
task cleanup: :environment do
|
|
6
|
+
deleted = Custos::Session.where(created_at: ..Custos.configuration.session_expiry.seconds.ago).delete_all
|
|
7
|
+
puts "Deleted #{deleted} expired sessions"
|
|
8
|
+
|
|
9
|
+
deleted = Custos::Session.revoked.where(revoked_at: ..30.days.ago).delete_all
|
|
10
|
+
puts "Deleted #{deleted} old revoked sessions"
|
|
11
|
+
|
|
12
|
+
if defined?(Custos::MagicLinkToken)
|
|
13
|
+
deleted = Custos::MagicLinkToken.where.not(used_at: nil).delete_all
|
|
14
|
+
deleted += Custos::MagicLinkToken.where(expires_at: ..Time.current).delete_all
|
|
15
|
+
puts "Deleted #{deleted} used/expired magic links"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
if defined?(Custos::RememberToken)
|
|
19
|
+
deleted = Custos::RememberToken.where(expires_at: ..Time.current).delete_all
|
|
20
|
+
puts "Deleted #{deleted} expired remember tokens"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
if defined?(Custos::ApiToken)
|
|
24
|
+
deleted = Custos::ApiToken.where.not(expires_at: nil).where(expires_at: ..Time.current).delete_all
|
|
25
|
+
puts "Deleted #{deleted} expired API tokens"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require 'active_support/security_utils'
|
|
6
|
+
|
|
7
|
+
module Custos
|
|
8
|
+
class TokenGenerator
|
|
9
|
+
def self.generate(byte_length: nil)
|
|
10
|
+
length = byte_length || Custos.configuration.token_length
|
|
11
|
+
SecureRandom.urlsafe_base64(length)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.digest(token)
|
|
15
|
+
secret = resolve_token_secret
|
|
16
|
+
OpenSSL::HMAC.hexdigest('SHA256', secret, token)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.secure_compare(value_a, value_b)
|
|
20
|
+
ActiveSupport::SecurityUtils.secure_compare(value_a, value_b)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.resolve_token_secret
|
|
24
|
+
secret = Custos.configuration.token_secret
|
|
25
|
+
return secret if secret
|
|
26
|
+
|
|
27
|
+
if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
28
|
+
base = Rails.application.secret_key_base
|
|
29
|
+
return base if base.present?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
raise Custos::Error, 'Custos.configuration.token_secret must be configured'
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private_class_method :resolve_token_secret
|
|
36
|
+
end
|
|
37
|
+
end
|