concerns_on_rails 1.7.0 → 1.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +43 -2
- data/lib/concerns_on_rails/legacy_aliases.rb +1 -0
- data/lib/concerns_on_rails/models/tokenizable.rb +145 -0
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5126c79ca466ff1a85a20eb0d4a29f7a2d471bc4186e7b4762481bfad2084f42
|
|
4
|
+
data.tar.gz: '086922bf94b7efa7ef99699e4d96ab4e24ae2e731c2308bd1b0a8471e1f065a2'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6b3e968d8cf97c9d11b14ff1d8374a64731ed3416d114ca0acf525a64f65843a35f0261da219d9f725e539eba7ac6460ba331a3aaf2b701b88627d841ac70a2a
|
|
7
|
+
data.tar.gz: 61f80dadd2112d36c1c1ceaf88fe01fb5dcf68f22911e1535e68414ecdbaca07ca363365c7ec3032c2698aec1cb0929d054036d2b8ebf73d1c347093976f3bb4
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.8.2 (2026-05-22)
|
|
4
|
+
|
|
5
|
+
### Internal
|
|
6
|
+
- Regenerated `Gemfile.lock` so the pinned `concerns_on_rails` version matches the gemspec. No behavior change.
|
|
7
|
+
|
|
8
|
+
### Notes
|
|
9
|
+
- The `v1.8.1` tag was pushed but failed CI (`bundle install --deployment` rejected the stale `Gemfile.lock`); `1.8.2` is the first usable release of the Tokenizable concern.
|
|
10
|
+
|
|
11
|
+
## 1.8.1 (2026-05-22)
|
|
12
|
+
|
|
13
|
+
### Internal
|
|
14
|
+
- Refactored `Models::Tokenizable` `class_methods` blocks to satisfy `Metrics/BlockLength`. No behavior change.
|
|
15
|
+
|
|
16
|
+
### Notes
|
|
17
|
+
- The `v1.8.0` tag was pushed but failed RuboCop; `1.8.1` is the first usable release of the Tokenizable concern.
|
|
18
|
+
|
|
19
|
+
## 1.8.0 (2026-05-22)
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- **Models::Tokenizable**: Security-token generation for API keys, invite codes, share links, password-reset tokens. Each `tokenizable_by` call adds an independently-configured field (one model can hold many tokens). Defaults to 32-char URL-safe values; also supports `:hex`, `:alphanumeric`, and `:numeric` types with a configurable `length:`. Auto-generates on create with best-effort uniqueness retry, and provides `regenerate_<field>!`, `revoke_<field>!`, `<field>?`, and a timing-safe `.authenticate_by_<field>` class method.
|
|
23
|
+
|
|
3
24
|
## 1.7.0 (2026-05-21)
|
|
4
25
|
|
|
5
26
|
### Added
|
data/README.md
CHANGED
|
@@ -35,6 +35,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
35
35
|
- [Normalizable](#-normalizable) — attribute normalization (`:email`, `:phone`, …)
|
|
36
36
|
- [Searchable](#-searchable) — LIKE/ILIKE search across configured columns
|
|
37
37
|
- [Activatable](#-activatable) — boolean active/inactive toggle
|
|
38
|
+
- [Tokenizable](#-tokenizable) — security tokens with timing-safe lookup
|
|
38
39
|
- **Controller concerns**
|
|
39
40
|
- [Paginatable](#-paginatable) — offset pagination with headers
|
|
40
41
|
- [Filterable](#-filterable) — declarative URL-param filters
|
|
@@ -50,7 +51,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
50
51
|
|
|
51
52
|
## ✨ Why this gem?
|
|
52
53
|
|
|
53
|
-
- **
|
|
54
|
+
- **Eleven model concerns + five controller concerns**, all production-ready
|
|
54
55
|
- **One include, one macro** — no boilerplate, no glue code
|
|
55
56
|
- **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
|
|
56
57
|
- **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
|
|
@@ -63,7 +64,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
63
64
|
Add to your application's `Gemfile`:
|
|
64
65
|
|
|
65
66
|
```ruby
|
|
66
|
-
gem "concerns_on_rails", "~> 1.
|
|
67
|
+
gem "concerns_on_rails", "~> 1.8"
|
|
67
68
|
```
|
|
68
69
|
|
|
69
70
|
Or pull the latest from GitHub:
|
|
@@ -485,6 +486,46 @@ Subscription.inactive # WHERE active = FALSE OR active IS NULL
|
|
|
485
486
|
|
|
486
487
|
---
|
|
487
488
|
|
|
489
|
+
## 🔑 Tokenizable
|
|
490
|
+
|
|
491
|
+
Generate and manage security tokens — API keys, invite codes, share links, password-reset tokens. One model can declare any number of independently-configured token fields.
|
|
492
|
+
|
|
493
|
+
```ruby
|
|
494
|
+
class User < ApplicationRecord
|
|
495
|
+
include ConcernsOnRails::Tokenizable
|
|
496
|
+
|
|
497
|
+
tokenizable_by :api_token # 32-char URL-safe
|
|
498
|
+
tokenizable_by :reset_password_token, length: 24
|
|
499
|
+
tokenizable_by :invite_code, type: :alphanumeric, length: 8
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
user = User.create! # all three tokens auto-generated
|
|
503
|
+
user.api_token # => "k3Jf...g2" (32 URL-safe chars)
|
|
504
|
+
user.api_token? # => true
|
|
505
|
+
|
|
506
|
+
user.regenerate_api_token! # rotates and persists
|
|
507
|
+
user.revoke_api_token! # nils the column
|
|
508
|
+
|
|
509
|
+
User.find_by_api_token(token) # Rails default
|
|
510
|
+
User.authenticate_by_api_token(token) # timing-safe; returns user or nil
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
**Options**
|
|
514
|
+
|
|
515
|
+
| Option | Default | Notes |
|
|
516
|
+
| -------- | ----------- | ------------------------------------------------------------- |
|
|
517
|
+
| `type:` | `:urlsafe` | One of `:urlsafe`, `:hex`, `:alphanumeric`, `:numeric` |
|
|
518
|
+
| `length:`| `32` | Character length of the generated token |
|
|
519
|
+
|
|
520
|
+
**Notes**
|
|
521
|
+
- URL-safe by default (`A–Z`, `a–z`, `0–9`, `-`, `_`) — drop straight into URLs and headers.
|
|
522
|
+
- Caller-supplied values are respected: `User.create!(api_token: "preset")` won't be overwritten.
|
|
523
|
+
- Generation does a best-effort uniqueness check before insert and retries up to 10 times. Pair with a `unique` DB index for real safety, especially for short alphanumeric/numeric codes.
|
|
524
|
+
- `.authenticate_by_<field>` uses `ActiveSupport::SecurityUtils.secure_compare` to avoid leaking partial matches via response timing.
|
|
525
|
+
- Distinct from `Hashable`: Hashable handles a single random field; Tokenizable focuses on security tokens (multi-field, URL-safe default, timing-safe lookup, revocation).
|
|
526
|
+
|
|
527
|
+
---
|
|
528
|
+
|
|
488
529
|
# 🎮 Controller Concerns
|
|
489
530
|
|
|
490
531
|
Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
require "active_support/security_utils"
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module ConcernsOnRails
|
|
6
|
+
module Models
|
|
7
|
+
# Generates and manages security tokens (API keys, invite codes, share links).
|
|
8
|
+
#
|
|
9
|
+
# class User < ApplicationRecord
|
|
10
|
+
# include ConcernsOnRails::Tokenizable
|
|
11
|
+
#
|
|
12
|
+
# tokenizable_by :api_token # 32-char URL-safe
|
|
13
|
+
# tokenizable_by :reset_password_token, length: 24
|
|
14
|
+
# tokenizable_by :invite_code, type: :alphanumeric, length: 8
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# user = User.create! # tokens auto-generated on create
|
|
18
|
+
# user.regenerate_api_token! # new value, persisted
|
|
19
|
+
# user.revoke_api_token! # sets the column to nil
|
|
20
|
+
# user.api_token? # true if present
|
|
21
|
+
#
|
|
22
|
+
# User.find_by_api_token(token) # Rails default
|
|
23
|
+
# User.authenticate_by_api_token(token) # timing-safe lookup, returns record or nil
|
|
24
|
+
#
|
|
25
|
+
# Unlike Hashable, one model can declare multiple token fields, generation is
|
|
26
|
+
# URL-safe by default, and `assign_tokenizable_value` retries on uniqueness
|
|
27
|
+
# collisions before insert (best-effort; pair with a unique DB index).
|
|
28
|
+
module Tokenizable
|
|
29
|
+
extend ActiveSupport::Concern
|
|
30
|
+
|
|
31
|
+
VALID_TYPES = %i[urlsafe hex alphanumeric numeric].freeze
|
|
32
|
+
ALPHANUMERIC_ALPHABET = (("A".."Z").to_a + ("a".."z").to_a + ("0".."9").to_a).freeze
|
|
33
|
+
NUMERIC_ALPHABET = ("0".."9").to_a.freeze
|
|
34
|
+
MAX_GENERATION_ATTEMPTS = 10
|
|
35
|
+
|
|
36
|
+
included do
|
|
37
|
+
class_attribute :tokenizable_fields, instance_accessor: false, default: {}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class_methods do
|
|
41
|
+
# Configure a tokenizable field.
|
|
42
|
+
#
|
|
43
|
+
# Options:
|
|
44
|
+
# type: one of :urlsafe (default), :hex, :alphanumeric, :numeric
|
|
45
|
+
# length: character length of the generated token (default 32)
|
|
46
|
+
def tokenizable_by(field, type: :urlsafe, length: 32)
|
|
47
|
+
field = field.to_sym
|
|
48
|
+
type = type.to_sym
|
|
49
|
+
length = length.to_i
|
|
50
|
+
|
|
51
|
+
validate_tokenizable_options!(field, type, length)
|
|
52
|
+
|
|
53
|
+
# Build a fresh hash so subclasses don't mutate the parent's config.
|
|
54
|
+
self.tokenizable_fields = tokenizable_fields.merge(field => { type: type, length: length })
|
|
55
|
+
|
|
56
|
+
before_create -> { assign_tokenizable_value(field) }
|
|
57
|
+
|
|
58
|
+
define_tokenizable_methods(field)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Generate a new random value for the given field using its configured type/length.
|
|
62
|
+
def generate_tokenizable_value(field)
|
|
63
|
+
config = tokenizable_fields.fetch(field) do
|
|
64
|
+
raise ArgumentError, "ConcernsOnRails::Models::Tokenizable: '#{field}' is not a tokenizable field"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
length = config[:length]
|
|
68
|
+
|
|
69
|
+
case config[:type]
|
|
70
|
+
when :urlsafe then SecureRandom.urlsafe_base64(length)[0, length]
|
|
71
|
+
when :hex then SecureRandom.hex((length + 1) / 2)[0, length]
|
|
72
|
+
when :alphanumeric then random_string_from_alphabet(ALPHANUMERIC_ALPHABET, length)
|
|
73
|
+
when :numeric then random_string_from_alphabet(NUMERIC_ALPHABET, length)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class_methods do
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def define_tokenizable_methods(field)
|
|
82
|
+
define_method("regenerate_#{field}!") { update!(field => self.class.generate_tokenizable_value(field)) }
|
|
83
|
+
define_method("revoke_#{field}!") { update!(field => nil) }
|
|
84
|
+
define_method("#{field}?") { self[field].present? }
|
|
85
|
+
define_singleton_method("authenticate_by_#{field}") { |value| timing_safe_find(field, value) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def timing_safe_find(field, value)
|
|
89
|
+
return nil if value.blank?
|
|
90
|
+
|
|
91
|
+
candidate = find_by(field => value)
|
|
92
|
+
return nil unless candidate
|
|
93
|
+
|
|
94
|
+
stored = candidate[field].to_s
|
|
95
|
+
given = value.to_s
|
|
96
|
+
return nil unless stored.bytesize == given.bytesize
|
|
97
|
+
|
|
98
|
+
ActiveSupport::SecurityUtils.secure_compare(stored, given) ? candidate : nil
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
class_methods do
|
|
103
|
+
def validate_tokenizable_options!(field, type, length)
|
|
104
|
+
unless column_names.include?(field.to_s)
|
|
105
|
+
raise ArgumentError,
|
|
106
|
+
"ConcernsOnRails::Models::Tokenizable: tokenizable field '#{field}' does not exist in the database"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
unless VALID_TYPES.include?(type)
|
|
110
|
+
raise ArgumentError,
|
|
111
|
+
"ConcernsOnRails::Models::Tokenizable: unknown type '#{type}'. Valid types: #{VALID_TYPES.join(', ')}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
return if length.positive?
|
|
115
|
+
|
|
116
|
+
raise ArgumentError, "ConcernsOnRails::Models::Tokenizable: length must be a positive integer"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def random_string_from_alphabet(alphabet, length)
|
|
120
|
+
Array.new(length) { alphabet[SecureRandom.random_number(alphabet.size)] }.join
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private :validate_tokenizable_options!, :random_string_from_alphabet
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Assigns the generated value only when blank, so callers can pass an explicit one.
|
|
127
|
+
# Retries up to MAX_GENERATION_ATTEMPTS times if the in-Ruby uniqueness check hits a
|
|
128
|
+
# collision — useful for short codes; a unique DB index is still the real guarantee.
|
|
129
|
+
def assign_tokenizable_value(field)
|
|
130
|
+
return if self[field].present?
|
|
131
|
+
|
|
132
|
+
MAX_GENERATION_ATTEMPTS.times do
|
|
133
|
+
candidate = self.class.generate_tokenizable_value(field)
|
|
134
|
+
unless self.class.unscoped.exists?(field => candidate)
|
|
135
|
+
self[field] = candidate
|
|
136
|
+
return
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
raise "ConcernsOnRails::Models::Tokenizable: could not generate a unique value for '#{field}' " \
|
|
141
|
+
"after #{MAX_GENERATION_ATTEMPTS} attempts — consider a longer length or a larger alphabet"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
data/lib/concerns_on_rails.rb
CHANGED
|
@@ -17,6 +17,7 @@ require "concerns_on_rails/models/expirable"
|
|
|
17
17
|
require "concerns_on_rails/models/normalizable"
|
|
18
18
|
require "concerns_on_rails/models/searchable"
|
|
19
19
|
require "concerns_on_rails/models/activatable"
|
|
20
|
+
require "concerns_on_rails/models/tokenizable"
|
|
20
21
|
|
|
21
22
|
# Controller concerns
|
|
22
23
|
require "concerns_on_rails/controllers/paginatable"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: concerns_on_rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.8.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ethan Nguyen
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-05-
|
|
11
|
+
date: 2026-05-22 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -86,6 +86,7 @@ files:
|
|
|
86
86
|
- lib/concerns_on_rails/models/sluggable.rb
|
|
87
87
|
- lib/concerns_on_rails/models/soft_deletable.rb
|
|
88
88
|
- lib/concerns_on_rails/models/sortable.rb
|
|
89
|
+
- lib/concerns_on_rails/models/tokenizable.rb
|
|
89
90
|
- lib/concerns_on_rails/version.rb
|
|
90
91
|
homepage: https://github.com/VSN2015/concerns_on_rails
|
|
91
92
|
licenses:
|