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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +36 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +117 -0
  5. data/lib/custos/authenticatable.rb +30 -0
  6. data/lib/custos/callback_registry.rb +37 -0
  7. data/lib/custos/configuration.rb +35 -0
  8. data/lib/custos/controller_helpers.rb +47 -0
  9. data/lib/custos/mfa_encryptor.rb +63 -0
  10. data/lib/custos/model_config.rb +36 -0
  11. data/lib/custos/models/api_token.rb +23 -0
  12. data/lib/custos/models/magic_link_token.rb +13 -0
  13. data/lib/custos/models/mfa_credential.rb +22 -0
  14. data/lib/custos/models/remember_token.rb +11 -0
  15. data/lib/custos/plugin.rb +30 -0
  16. data/lib/custos/plugins/api_tokens.rb +48 -0
  17. data/lib/custos/plugins/email_confirmation.rb +59 -0
  18. data/lib/custos/plugins/lockout.rb +116 -0
  19. data/lib/custos/plugins/magic_link.rb +60 -0
  20. data/lib/custos/plugins/mfa.rb +185 -0
  21. data/lib/custos/plugins/password.rb +98 -0
  22. data/lib/custos/plugins/remember_me.rb +56 -0
  23. data/lib/custos/railtie.rb +15 -0
  24. data/lib/custos/session.rb +16 -0
  25. data/lib/custos/session_manager.rb +51 -0
  26. data/lib/custos/tasks/cleanup.rake +28 -0
  27. data/lib/custos/token_generator.rb +37 -0
  28. data/lib/custos/version.rb +5 -0
  29. data/lib/custos.rb +57 -0
  30. data/lib/generators/custos/install/install_generator.rb +23 -0
  31. data/lib/generators/custos/install/templates/create_custos_sessions.rb.tt +19 -0
  32. data/lib/generators/custos/install/templates/custos_initializer.rb.tt +12 -0
  33. data/lib/generators/custos/model/model_generator.rb +89 -0
  34. data/lib/generators/custos/model/templates/add_custos_columns.rb.tt +18 -0
  35. data/lib/generators/custos/model/templates/create_custos_api_tokens.rb.tt +18 -0
  36. data/lib/generators/custos/model/templates/create_custos_magic_links.rb.tt +17 -0
  37. data/lib/generators/custos/model/templates/create_custos_mfa_credentials.rb.tt +16 -0
  38. data/lib/generators/custos/model/templates/create_custos_remember_tokens.rb.tt +16 -0
  39. 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Custos
4
+ VERSION = '0.1.0'
5
+ end