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
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
|
+
[](https://rubygems.org/gems/custos)
|
|
4
|
+
[](https://www.ruby-lang.org/)
|
|
5
|
+
[](https://rubyonrails.org/)
|
|
6
|
+
[](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)
|