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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 24708985bf3ae2ecd4778c38c0793b847aa099fe64bbbbdce10ded72300311d8
4
+ data.tar.gz: fe8af54c9038f8642f3f5de46bfb23818551f981bfecb9f936cb22e2bb205db7
5
+ SHA512:
6
+ metadata.gz: 53f840b0476ababd9dcd88303b1b1eb310d31bcae0a77ca2ce192a6470ff9f0e555d41cf45266994572b14a55cbbbb9c8ff0db1176d0464136d33152b85ec00b
7
+ data.tar.gz: b3fc5c6c76b7f6a4c4364abf12a7d7a23bfbb6b93c57d2449f74419f83f4c7a3f011c69629b7f3d7e46f7c4b6920faaf4682344a2d9f48d0bc556790fa45fd17
data/CHANGELOG.md ADDED
@@ -0,0 +1,36 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ This project adheres to [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [0.1.0] - 2026-03-16
8
+
9
+ ### Added
10
+
11
+ - Core authentication framework with `Custos::Authenticatable` concern
12
+ - Plugin system with per-model configuration DSL
13
+ - Session management with HMAC-SHA256 token digests and scope filtering
14
+ - Controller helpers: `custos_authenticate!`, `custos_current`, `custos_session`
15
+ - Token extraction from signed cookies and Authorization header
16
+ - Callback system with configurable error strategy (`:log` / `:raise`)
17
+ - Install and model generators
18
+
19
+ #### Plugins
20
+
21
+ - **Password** — Argon2id hashing, configurable complexity rules, timing-safe dummy verify
22
+ - **Magic Link** — Passwordless email authentication with cooldown and expiry
23
+ - **API Tokens** — Bearer token authentication with optional expiration
24
+ - **MFA** — TOTP, backup codes (48-bit entropy), SMS verification
25
+ - **Lockout** — Atomic SQL-based account lockout after failed attempts
26
+ - **Email Confirmation** — Token-based email verification with configurable expiry
27
+ - **Remember Me** — Persistent session tokens with rotation on use
28
+
29
+ #### Security
30
+
31
+ - HMAC-SHA256 token digests (plain-text tokens never persisted)
32
+ - AES-256-GCM encryption for MFA secrets via `Custos::MfaEncryptor`
33
+ - Timing-safe comparisons for all token verification
34
+ - Atomic lockout updates (single SQL statement, no race conditions)
35
+ - MFA rate limiting via Lockout plugin integration
36
+ - Cleanup rake task for expired sessions and tokens
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Ingvar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # Custos
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/custos)](https://rubygems.org/gems/custos)
4
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-red)](https://www.ruby-lang.org/)
5
+ [![Rails](https://img.shields.io/badge/rails-%3E%3D%207.0-red)](https://rubyonrails.org/)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE.txt)
7
+
8
+ Plugin-based authentication for Ruby on Rails. Modern, modular authentication that supports password, magic link, API tokens, MFA, and more — all as composable plugins.
9
+
10
+ **No magic controllers.** Custos provides services and helpers, not generated controllers and routes. You stay in control. Email delivery, SMS, and other side effects are handled through callbacks — use Action Mailer, Postmark, or anything else.
11
+
12
+ ## Quick Start
13
+
14
+ Add to your Gemfile:
15
+
16
+ ```ruby
17
+ gem "custos"
18
+ ```
19
+
20
+ Run the install generator:
21
+
22
+ ```bash
23
+ bundle install
24
+ rails generate custos:install
25
+ rails db:migrate
26
+ ```
27
+
28
+ Generate model authentication (pick the plugins you need):
29
+
30
+ ```bash
31
+ rails generate custos:model User password magic_link lockout email_confirmation
32
+ rails db:migrate
33
+ ```
34
+
35
+ Configure your model:
36
+
37
+ ```ruby
38
+ class User < ApplicationRecord
39
+ include Custos::Authenticatable
40
+
41
+ custos do
42
+ plugin :password, min_length: 10, require_digit: true
43
+ plugin :magic_link
44
+ plugin :lockout, max_attempts: 5
45
+ plugin :email_confirmation
46
+
47
+ on(:magic_link_created) do |record, token|
48
+ AuthMailer.magic_link(record, token).deliver_later
49
+ end
50
+
51
+ on(:email_confirmation_requested) do |record, token|
52
+ AuthMailer.confirm_email(record, token).deliver_later
53
+ end
54
+ end
55
+ end
56
+ ```
57
+
58
+ Authenticate in controllers:
59
+
60
+ ```ruby
61
+ class DashboardController < ApplicationController
62
+ before_action :custos_authenticate!
63
+
64
+ def show
65
+ @user = custos_current
66
+ end
67
+ end
68
+ ```
69
+
70
+ ## Plugins
71
+
72
+ | Plugin | Description |
73
+ |--------|-------------|
74
+ | Password | Email + password authentication with Argon2 hashing |
75
+ | Magic Link | Passwordless authentication via email links |
76
+ | API Tokens | Bearer token authentication for APIs |
77
+ | MFA | TOTP, backup codes, and SMS verification |
78
+ | Lockout | Account lockout after failed attempts |
79
+ | Email Confirmation | Email verification on sign-up |
80
+ | Remember Me | Long-lived sessions via persistent tokens |
81
+
82
+ Each plugin is standalone. Use password authentication for users and API tokens for service accounts — on the same app, with per-model configuration.
83
+
84
+ ## Security
85
+
86
+ - **Argon2id** password hashing (resistant to GPU/ASIC attacks)
87
+ - **HMAC-SHA256** token digests — plain-text tokens are never persisted
88
+ - **AES-256-GCM** encrypted MFA secrets
89
+ - **Timing-safe** comparisons for all token verification
90
+ - **Atomic lockout** — race-condition-free via single SQL UPDATE
91
+ - **256-bit entropy** session tokens
92
+
93
+ ## Configuration
94
+
95
+ ```ruby
96
+ # config/initializers/custos.rb
97
+ Custos.configure do |config|
98
+ config.session_expiry = 24 * 60 * 60 # 24 hours
99
+ config.token_length = 32 # bytes
100
+ config.token_secret = Rails.application.secret_key_base
101
+ config.mfa_encryption_key = ENV["CUSTOS_MFA_KEY"] # optional, enables MFA secret encryption
102
+ config.callback_error_strategy = :log # :log or :raise
103
+ end
104
+ ```
105
+
106
+ ## Documentation
107
+
108
+ Full documentation is available at **[github.com/supostat/Custos](https://github.com/supostat/Custos)**.
109
+
110
+ ## Requirements
111
+
112
+ - Ruby 3.2+
113
+ - Rails 7.0+
114
+
115
+ ## License
116
+
117
+ Released under the [MIT License](LICENSE.txt).
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module Custos
6
+ module Authenticatable
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ has_many :custos_sessions,
11
+ class_name: 'Custos::Session',
12
+ as: :authenticatable,
13
+ dependent: :destroy
14
+ end
15
+
16
+ class_methods do
17
+ def custos(&block)
18
+ @custos_config = Custos::ModelConfig.new(self)
19
+ @custos_config.instance_eval(&block)
20
+ @custos_config
21
+ end
22
+
23
+ def custos_config
24
+ return @custos_config if instance_variable_defined?(:@custos_config)
25
+
26
+ superclass.custos_config if superclass.respond_to?(:custos_config)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Custos
4
+ class CallbackRegistry
5
+ def self.fire(model_class, event_name, *args)
6
+ config = model_class.custos_config
7
+ return unless config
8
+
9
+ config.callbacks[event_name].each do |callback|
10
+ callback.call(*args)
11
+ rescue StandardError => e
12
+ handle_callback_error(event_name, e)
13
+ end
14
+ end
15
+
16
+ def self.fire_hooks(model_class, event_name, *args)
17
+ config = model_class.custos_config
18
+ return unless config
19
+
20
+ config.hooks[event_name].each { |hook| hook.call(*args) }
21
+ end
22
+
23
+ def self.handle_callback_error(event_name, error)
24
+ strategy = Custos.configuration.callback_error_strategy
25
+
26
+ raise error if strategy == :raise
27
+
28
+ if defined?(Rails.logger)
29
+ Rails.logger.error "[Custos] Callback error on #{event_name}: #{error.message}"
30
+ else
31
+ warn "[Custos] Callback error on #{event_name}: #{error.message}"
32
+ end
33
+ end
34
+
35
+ private_class_method :handle_callback_error
36
+ end
37
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Custos
4
+ class Configuration
5
+ attr_accessor :session_expiry,
6
+ :session_renewal_interval,
7
+ :token_length,
8
+ :scope_map,
9
+ :token_secret,
10
+ :mfa_encryption_key,
11
+ :callback_error_strategy
12
+
13
+ def initialize
14
+ @session_expiry = 24 * 60 * 60 # 24 hours in seconds
15
+ @session_renewal_interval = 60 * 60 # 1 hour in seconds
16
+ @token_length = 32 # bytes
17
+ @scope_map = {} # e.g. { user: "User", api_client: "ApiClient" }
18
+ @token_secret = nil # HMAC secret; falls back to Rails.application.secret_key_base
19
+ @mfa_encryption_key = nil # AES-256-GCM key for MFA secrets; nil = plaintext
20
+ @callback_error_strategy = :log # :log or :raise
21
+ end
22
+
23
+ VALID_CLASS_NAME = /\A[A-Z][A-Za-z0-9]*(::[A-Z][A-Za-z0-9]*)*\z/
24
+
25
+ def model_class_for_scope(scope)
26
+ class_name = @scope_map[scope.to_sym]
27
+ return nil unless class_name
28
+ return nil unless class_name.match?(VALID_CLASS_NAME)
29
+
30
+ class_name.constantize
31
+ rescue NameError
32
+ nil
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module Custos
6
+ module ControllerHelpers
7
+ extend ActiveSupport::Concern
8
+
9
+ def custos_authenticate!(scope: :user)
10
+ return if custos_authenticated?(scope: scope)
11
+
12
+ raise Custos::NotAuthenticatedError, 'Authentication required'
13
+ end
14
+
15
+ def custos_authenticated?(scope: :user)
16
+ custos_current(scope: scope).present?
17
+ end
18
+
19
+ def custos_current(scope: :user)
20
+ ivar = "@custos_current_#{scope}"
21
+ return instance_variable_get(ivar) if instance_variable_defined?(ivar)
22
+
23
+ session = resolve_custos_session(scope)
24
+ instance_variable_set(ivar, session&.authenticatable)
25
+ end
26
+
27
+ def custos_session
28
+ token = extract_custos_token
29
+ @custos_session ||= Custos::SessionManager.find_by_token(token) if token
30
+ end
31
+
32
+ private
33
+
34
+ def resolve_custos_session(scope)
35
+ token = extract_custos_token
36
+ return nil unless token
37
+
38
+ model_class = Custos.configuration.model_class_for_scope(scope)
39
+ Custos::SessionManager.find_by_token(token, authenticatable_type: model_class&.name)
40
+ end
41
+
42
+ def extract_custos_token
43
+ cookies.signed[:custos_session_token] ||
44
+ request.headers['Authorization']&.delete_prefix('Bearer ')
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'base64'
5
+
6
+ module Custos
7
+ class MfaEncryptor
8
+ ENCRYPTED_PREFIX = 'enc:'
9
+ CIPHER = 'aes-256-gcm'
10
+ IV_LENGTH = 12
11
+ TAG_LENGTH = 16
12
+
13
+ class << self
14
+ def encrypt(plaintext)
15
+ key = encryption_key
16
+ return plaintext unless key
17
+
18
+ cipher = OpenSSL::Cipher.new(CIPHER)
19
+ cipher.encrypt
20
+ iv = cipher.random_iv
21
+ cipher.key = derive_key(key)
22
+ cipher.auth_data = ''
23
+
24
+ encrypted = cipher.update(plaintext) + cipher.final
25
+ tag = cipher.auth_tag
26
+
27
+ "#{ENCRYPTED_PREFIX}#{Base64.strict_encode64(iv + tag + encrypted)}"
28
+ end
29
+
30
+ def decrypt(ciphertext)
31
+ key = encryption_key
32
+ return ciphertext unless key
33
+ return ciphertext unless ciphertext&.start_with?(ENCRYPTED_PREFIX)
34
+
35
+ raw = Base64.strict_decode64(ciphertext.delete_prefix(ENCRYPTED_PREFIX))
36
+ iv = raw.byteslice(0, IV_LENGTH)
37
+ tag = raw.byteslice(IV_LENGTH, TAG_LENGTH)
38
+ encrypted = raw.byteslice((IV_LENGTH + TAG_LENGTH)..)
39
+
40
+ cipher = OpenSSL::Cipher.new(CIPHER)
41
+ cipher.decrypt
42
+ cipher.key = derive_key(key)
43
+ cipher.iv = iv
44
+ cipher.auth_tag = tag
45
+ cipher.auth_data = ''
46
+
47
+ cipher.update(encrypted) + cipher.final
48
+ rescue ArgumentError, OpenSSL::Cipher::CipherError => e
49
+ raise Custos::DecryptionError, "MFA decryption failed: #{e.class}"
50
+ end
51
+
52
+ private
53
+
54
+ def encryption_key
55
+ Custos.configuration.mfa_encryption_key
56
+ end
57
+
58
+ def derive_key(key)
59
+ OpenSSL::Digest::SHA256.digest(key)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Custos
4
+ class ModelConfig
5
+ attr_reader :model_class, :loaded_plugins, :callbacks, :hooks
6
+
7
+ def initialize(model_class)
8
+ @model_class = model_class
9
+ @loaded_plugins = {}
10
+ @callbacks = Hash.new { |hash, key| hash[key] = [] }
11
+ @hooks = Hash.new { |hash, key| hash[key] = [] }
12
+ end
13
+
14
+ def plugin(name, **options)
15
+ mod = Custos::Plugin.resolve(name)
16
+ mod.apply(@model_class, **options)
17
+ @loaded_plugins[name.to_sym] = options
18
+ end
19
+
20
+ def on(event_name, &block)
21
+ @callbacks[event_name.to_sym] << block
22
+ end
23
+
24
+ def hook(event_name, &block)
25
+ @hooks[event_name.to_sym] << block
26
+ end
27
+
28
+ def plugin_enabled?(name)
29
+ @loaded_plugins.key?(name.to_sym)
30
+ end
31
+
32
+ def plugin_options(name)
33
+ @loaded_plugins.fetch(name.to_sym) { raise Custos::UnknownPluginError, "Plugin #{name} is not loaded" }
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Custos
4
+ class ApiToken < ActiveRecord::Base
5
+ self.table_name = 'custos_api_tokens'
6
+
7
+ belongs_to :authenticatable, polymorphic: true
8
+
9
+ scope :active, -> { where(revoked_at: nil).where('expires_at IS NULL OR expires_at > ?', Time.current) }
10
+
11
+ def revoke!
12
+ update!(revoked_at: Time.current)
13
+ end
14
+
15
+ def revoked?
16
+ revoked_at.present?
17
+ end
18
+
19
+ def expired?
20
+ expires_at.present? && expires_at <= Time.current
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Custos
4
+ class MagicLinkToken < ActiveRecord::Base
5
+ self.table_name = 'custos_magic_links'
6
+
7
+ belongs_to :authenticatable, polymorphic: true
8
+
9
+ scope :unused, -> { where(used_at: nil) }
10
+ scope :not_expired, -> { where('expires_at > ?', Time.current) }
11
+ scope :valid_tokens, -> { unused.not_expired }
12
+ end
13
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Custos
4
+ class MfaCredential < ActiveRecord::Base
5
+ self.table_name = 'custos_mfa_credentials'
6
+
7
+ belongs_to :authenticatable, polymorphic: true
8
+
9
+ scope :enabled, -> { where.not(enabled_at: nil) }
10
+ scope :by_method, ->(method_name) { where(method: method_name) }
11
+
12
+ def secret_data
13
+ Custos::MfaEncryptor.decrypt(super)
14
+ rescue Custos::DecryptionError
15
+ nil
16
+ end
17
+
18
+ def secret_data=(value)
19
+ super(Custos::MfaEncryptor.encrypt(value))
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Custos
4
+ class RememberToken < ActiveRecord::Base
5
+ self.table_name = 'custos_remember_tokens'
6
+
7
+ belongs_to :authenticatable, polymorphic: true
8
+
9
+ scope :not_expired, -> { where('expires_at > ?', Time.current) }
10
+ end
11
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Custos
4
+ module Plugin
5
+ @registry = {}
6
+ @mutex = Mutex.new
7
+
8
+ class << self
9
+ def register(name, mod)
10
+ @mutex.synchronize { @registry[name.to_sym] = mod }
11
+ end
12
+
13
+ def resolve(name)
14
+ @mutex.synchronize do
15
+ @registry.fetch(name.to_sym) do
16
+ raise Custos::UnknownPluginError, "Unknown plugin: #{name}"
17
+ end
18
+ end
19
+ end
20
+
21
+ def registered?(name)
22
+ @mutex.synchronize { @registry.key?(name.to_sym) }
23
+ end
24
+
25
+ def registered_names
26
+ @mutex.synchronize { @registry.keys }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Custos
4
+ module Plugins
5
+ module ApiTokens
6
+ def self.apply(model_class, **_options)
7
+ model_class.has_many :custos_api_tokens,
8
+ class_name: 'Custos::ApiToken',
9
+ as: :authenticatable,
10
+ dependent: :destroy
11
+
12
+ model_class.include(InstanceMethods)
13
+ model_class.extend(ClassMethods)
14
+ end
15
+
16
+ module InstanceMethods
17
+ def generate_api_token(expires_in: nil)
18
+ token = Custos::TokenGenerator.generate
19
+
20
+ default_expiry = self.class.custos_config.plugin_options(:api_tokens).fetch(:default_expiry, nil)
21
+ expiry = expires_in || default_expiry
22
+
23
+ custos_api_tokens.create!(
24
+ token_digest: Custos::TokenGenerator.digest(token),
25
+ expires_at: expiry ? Time.current + expiry : nil
26
+ )
27
+ token
28
+ end
29
+ end
30
+
31
+ module ClassMethods
32
+ def authenticate_api_token(token)
33
+ digest = Custos::TokenGenerator.digest(token)
34
+ api_token = Custos::ApiToken.active.find_by(
35
+ token_digest: digest,
36
+ authenticatable_type: name
37
+ )
38
+ return nil unless api_token
39
+
40
+ api_token.update_column(:last_used_at, Time.current)
41
+ api_token.authenticatable
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+
48
+ Custos::Plugin.register(:api_tokens, Custos::Plugins::ApiTokens)
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Custos
4
+ module Plugins
5
+ module EmailConfirmation
6
+ DEFAULT_CONFIRMATION_EXPIRY = 24 * 60 * 60 # 24 hours in seconds
7
+
8
+ def self.apply(model_class, **_options)
9
+ model_class.include(InstanceMethods)
10
+ end
11
+
12
+ module InstanceMethods
13
+ def send_email_confirmation
14
+ token = Custos::TokenGenerator.generate
15
+ update!(
16
+ email_confirmation_token_digest: Custos::TokenGenerator.digest(token),
17
+ email_confirmation_sent_at: Time.current
18
+ )
19
+
20
+ Custos::CallbackRegistry.fire(self.class, :email_confirmation_requested, self, token)
21
+ token
22
+ end
23
+
24
+ def confirm_email!(token)
25
+ digest = Custos::TokenGenerator.digest(token)
26
+ stored = email_confirmation_token_digest || ''
27
+
28
+ token_matches = Custos::TokenGenerator.secure_compare(stored, digest)
29
+ return false unless token_matches
30
+ return false if confirmation_token_expired?
31
+
32
+ update!(
33
+ email_confirmed_at: Time.current,
34
+ email_confirmation_token_digest: nil
35
+ )
36
+
37
+ Custos::CallbackRegistry.fire(self.class, :email_confirmed, self)
38
+ true
39
+ end
40
+
41
+ def email_confirmed?
42
+ email_confirmed_at.present?
43
+ end
44
+
45
+ private
46
+
47
+ def confirmation_token_expired?
48
+ return true if email_confirmation_sent_at.nil?
49
+
50
+ options = self.class.custos_config.plugin_options(:email_confirmation)
51
+ expiry = options.fetch(:confirmation_expiry, DEFAULT_CONFIRMATION_EXPIRY)
52
+ email_confirmation_sent_at < expiry.seconds.ago
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ Custos::Plugin.register(:email_confirmation, Custos::Plugins::EmailConfirmation)