tokenzen 0.1.0 → 0.2.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 +4 -4
- data/README.md +61 -69
- data/lib/tokenzen/authenticatable.rb +103 -62
- data/lib/tokenzen/configuration.rb +63 -35
- data/lib/tokenzen/token_store.rb +236 -56
- data/lib/tokenzen/version.rb +1 -1
- metadata +2 -3
- data/lib/tokenzen/model.rb +0 -46
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 82f6a97feb3cf2582a57f606802d2978ecb8b7e59a761f5ca11b243235ec2665
|
|
4
|
+
data.tar.gz: d4bce0dc31a14c18d299b1bbfa02841d0be1271174dff28c994e0e72e3385e5a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 45e06ea452280b363a8552d6498331f7281a754307eddf72231f67b3d0983a462593ba7ca8fcbf5a884a66eea092e938c620bf02e91b5c4d1341b63cf8ee6fa5
|
|
7
|
+
data.tar.gz: 47f09407cbea9d9ed77f82a9e2aedc39ecf959fc9ef956ee442476b3266a66d8eaa6c6951550fe7d70d4f0e71c4f5617f8be38b053a88da75c2953e591650181
|
data/README.md
CHANGED
|
@@ -1,57 +1,52 @@
|
|
|
1
1
|
Tokenzen
|
|
2
2
|
=======
|
|
3
3
|
|
|
4
|
-
Tokenzen is a lightweight,
|
|
4
|
+
Tokenzen is a lightweight, session-based token authentication toolkit for Rails.
|
|
5
5
|
|
|
6
|
-
It provides secure,
|
|
6
|
+
It provides secure, encrypted access + refresh token management for any ActiveRecord model — not just User.
|
|
7
7
|
|
|
8
8
|
Tokenzen is designed to be:
|
|
9
9
|
- Model agnostic
|
|
10
10
|
- Multi-model compatible
|
|
11
|
+
- Multi-device aware
|
|
12
|
+
- Session-limited
|
|
13
|
+
- Secure refresh-rotation enabled
|
|
11
14
|
- Cache-backed and scalable
|
|
12
|
-
- Configurable
|
|
13
15
|
- Lightweight
|
|
14
16
|
- Easy to integrate
|
|
15
17
|
|
|
16
18
|
========================================
|
|
17
19
|
FEATURES
|
|
18
20
|
=========
|
|
19
|
-
|
|
20
21
|
- Works with any model (Admin, Customer, Account, etc.)
|
|
21
|
-
- Access
|
|
22
|
-
-
|
|
23
|
-
- Multi-device support
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
22
|
+
- Access + Refresh token generation
|
|
23
|
+
- Secure refresh token rotation (replay-safe)
|
|
24
|
+
- Multi-device login support
|
|
25
|
+
- Configurable max session limit
|
|
26
|
+
- Automatic session revocation on password change
|
|
27
|
+
- Logout from all devices
|
|
28
|
+
- Token validation via class-level API
|
|
27
29
|
- Configurable expiration
|
|
28
|
-
- AES-256 encryption of tokens
|
|
30
|
+
- AES-256-GCM encryption of tokens
|
|
29
31
|
- Rails auto-loading via Railtie
|
|
30
32
|
|
|
31
33
|
========================================
|
|
32
34
|
INSTALLATION
|
|
33
35
|
============
|
|
34
|
-
|
|
35
36
|
Add this to your application's Gemfile:
|
|
36
|
-
|
|
37
37
|
gem "tokenzen"
|
|
38
38
|
|
|
39
39
|
Then run:
|
|
40
|
-
|
|
41
40
|
bundle install
|
|
42
41
|
|
|
43
42
|
Or install manually:
|
|
44
|
-
|
|
45
43
|
gem install tokenzen
|
|
46
44
|
|
|
47
45
|
========================================
|
|
48
46
|
BASIC USAGE
|
|
49
47
|
===========
|
|
50
|
-
|
|
51
48
|
Include Tokenzen in any ActiveRecord model.
|
|
52
49
|
|
|
53
|
-
Example:
|
|
54
|
-
|
|
55
50
|
class Admin < ApplicationRecord
|
|
56
51
|
include Tokenzen::Authenticatable
|
|
57
52
|
tokenzen
|
|
@@ -67,42 +62,39 @@ You can use Tokenzen in multiple models at the same time:
|
|
|
67
62
|
========================================
|
|
68
63
|
GENERATE ACCESS + REFRESH TOKEN
|
|
69
64
|
===============================
|
|
70
|
-
|
|
71
65
|
admin = Admin.first
|
|
72
66
|
tokens = admin.generate_tokens
|
|
73
|
-
|
|
67
|
+
# tokens => { access_token: "...", refresh_token: "..." }
|
|
74
68
|
|
|
75
|
-
This
|
|
69
|
+
This:
|
|
70
|
+
- Creates a new session
|
|
71
|
+
- Enforces max session limit
|
|
72
|
+
- Stores tokens in cache
|
|
73
|
+
- Encrypts them using AES-256
|
|
74
|
+
- Returns encrypted tokens
|
|
76
75
|
|
|
77
76
|
========================================
|
|
78
|
-
|
|
77
|
+
AUTHENTICATE ACCESS TOKEN
|
|
79
78
|
=======================
|
|
80
|
-
|
|
81
|
-
Use the class-level `login` method to validate a token pair:
|
|
82
|
-
|
|
83
|
-
record = Admin.login(tokens[:access_token], tokens[:refresh_token])
|
|
84
|
-
# returns Admin instance if valid, nil if invalid
|
|
85
|
-
|
|
86
79
|
You can also validate token pair from an instance:
|
|
87
80
|
|
|
88
|
-
|
|
89
|
-
|
|
81
|
+
admin = Admin.validate_token(access_token)
|
|
82
|
+
# => Admin record or nil
|
|
90
83
|
|
|
91
84
|
========================================
|
|
92
|
-
REFRESH
|
|
85
|
+
REFRESH / ROTATE TOKENS
|
|
93
86
|
====================
|
|
94
|
-
|
|
95
87
|
Use the refresh token to generate a new access token:
|
|
96
88
|
|
|
97
|
-
new_tokens = Admin.
|
|
98
|
-
|
|
89
|
+
new_tokens = Admin.rotate_tokens(refresh_token)
|
|
90
|
+
# returns { access_token: "...", refresh_token: "..." }
|
|
99
91
|
|
|
100
92
|
========================================
|
|
101
93
|
LOGOUT / CLEAR ALL TOKENS
|
|
102
94
|
=========================
|
|
103
|
-
|
|
104
|
-
admin.
|
|
105
|
-
|
|
95
|
+
admin.logout(access_token)-> delete current session
|
|
96
|
+
admin.logout_all
|
|
97
|
+
# clears all access and refresh tokens for this record
|
|
106
98
|
|
|
107
99
|
========================================
|
|
108
100
|
CONFIGURATION
|
|
@@ -115,6 +107,7 @@ Create an initializer:
|
|
|
115
107
|
Tokenzen.configure do |config|
|
|
116
108
|
config.access_token_expiry = 2.days
|
|
117
109
|
config.refresh_token_expiry = 2.months
|
|
110
|
+
config.max_sessions = 3
|
|
118
111
|
config.secret_key = ENV["TOKENZEN_SECRET_KEY"] || Rails.application.secret_key_base
|
|
119
112
|
end
|
|
120
113
|
|
|
@@ -123,44 +116,42 @@ The gem automatically encrypts all tokens using AES-256 with this secret key.
|
|
|
123
116
|
========================================
|
|
124
117
|
HOW IT WORKS
|
|
125
118
|
=============
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
-
|
|
130
|
-
- Tokens are
|
|
131
|
-
-
|
|
132
|
-
-
|
|
119
|
+
When a user logs in:
|
|
120
|
+
- A new session_id is created.
|
|
121
|
+
- Secure random keys are generated for access + refresh tokens.
|
|
122
|
+
- Tokens are stored in cache (Redis recommended).
|
|
123
|
+
- Tokens are encrypted using AES-256-GCM.
|
|
124
|
+
- Sessions are tracked per model record.
|
|
125
|
+
- Oldest session is removed if max_sessions limit is reached.
|
|
133
126
|
|
|
134
127
|
Stored payload example:
|
|
135
128
|
|
|
136
129
|
{
|
|
137
130
|
"model" => "Admin",
|
|
138
131
|
"id" => 1,
|
|
139
|
-
"type" => "access" # or "refresh"
|
|
132
|
+
"type" => "access" # or "refresh",
|
|
133
|
+
"session_id" => "uuid"
|
|
140
134
|
}
|
|
141
135
|
|
|
142
136
|
This allows Tokenzen to work with any ActiveRecord model automatically.
|
|
143
137
|
|
|
144
138
|
========================================
|
|
145
|
-
|
|
139
|
+
SESSION MANAGEMENT
|
|
146
140
|
===============
|
|
141
|
+
Tokenzen supports:
|
|
142
|
+
- Multiple devices per user
|
|
143
|
+
- Configurable max session limit
|
|
144
|
+
- Automatic removal of oldest session when limit exceeded
|
|
145
|
+
- Full session revocation
|
|
147
146
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
- refresh_token
|
|
151
|
-
- password_digest
|
|
152
|
-
|
|
153
|
-
Tokenzen will automatically:
|
|
154
|
-
|
|
155
|
-
- Rotate the refresh token when password changes
|
|
156
|
-
- Clear all access tokens when refresh token changes
|
|
147
|
+
Example:
|
|
148
|
+
If max_sessions = 3
|
|
157
149
|
|
|
158
|
-
|
|
150
|
+
Logging in from 4th device will revoke the oldest session.
|
|
159
151
|
|
|
160
152
|
========================================
|
|
161
153
|
PRODUCTION RECOMMENDATION
|
|
162
154
|
==========================
|
|
163
|
-
|
|
164
155
|
Use Redis as your cache store for production environments:
|
|
165
156
|
|
|
166
157
|
config.cache_store = :redis_cache_store, { url: ENV["REDIS_URL"] }
|
|
@@ -178,27 +169,28 @@ REQUIREMENTS
|
|
|
178
169
|
========================================
|
|
179
170
|
SECURITY NOTES
|
|
180
171
|
================
|
|
181
|
-
|
|
182
|
-
-
|
|
183
|
-
-
|
|
184
|
-
-
|
|
185
|
-
-
|
|
172
|
+
- Tokens encrypted using AES-256-GCM
|
|
173
|
+
- Refresh tokens rotate on use
|
|
174
|
+
- Old refresh tokens invalidated immediately
|
|
175
|
+
- Sessions revocable instantly
|
|
176
|
+
- No tokens stored in database
|
|
177
|
+
- No fingerprint/device binding required
|
|
178
|
+
- Replay attack resistant refresh flow
|
|
186
179
|
|
|
187
180
|
========================================
|
|
188
181
|
ROADMAP
|
|
189
182
|
========
|
|
190
|
-
|
|
191
|
-
-
|
|
183
|
+
- Per-device logout
|
|
184
|
+
- Session listing API
|
|
185
|
+
- Sliding expiration
|
|
186
|
+
- Controller helpers
|
|
192
187
|
- Rack middleware
|
|
193
|
-
-
|
|
194
|
-
-
|
|
195
|
-
- Revokable single-session tokens
|
|
196
|
-
- OAuth support
|
|
188
|
+
- Optional JWT mode
|
|
189
|
+
- OAuth compatibility layer
|
|
197
190
|
|
|
198
191
|
========================================
|
|
199
192
|
CONTRIBUTING
|
|
200
193
|
==============
|
|
201
|
-
|
|
202
194
|
Bug reports and pull requests are welcome at:
|
|
203
195
|
|
|
204
196
|
https://github.com/stndrk/tokenzen
|
|
@@ -2,92 +2,133 @@
|
|
|
2
2
|
|
|
3
3
|
module Tokenzen
|
|
4
4
|
module Authenticatable
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
# ============================================================
|
|
8
|
+
# CALLBACKS
|
|
9
|
+
# ============================================================
|
|
10
|
+
included do
|
|
11
|
+
after_commit :_tokenzen_revoke_on_password_change, on: :update
|
|
12
|
+
after_commit :_tokenzen_revoke_all_on_destroy, on: :destroy
|
|
7
13
|
end
|
|
8
14
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
15
|
+
# ============================================================
|
|
16
|
+
# CLASS METHODS
|
|
17
|
+
# ============================================================
|
|
18
|
+
class_methods do
|
|
12
19
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
before_save :_tokenzen_rotate_refresh_token
|
|
17
|
-
around_save :_tokenzen_handle_refresh_rotation
|
|
18
|
-
after_destroy :_tokenzen_clear_tokens
|
|
20
|
+
# Enable token features in model
|
|
21
|
+
def tokenzen
|
|
22
|
+
include InstanceMethods unless included_modules.include?(InstanceMethods)
|
|
19
23
|
end
|
|
20
|
-
end
|
|
21
24
|
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
# -------------------------------
|
|
26
|
+
# VALIDATE ACCESS TOKEN
|
|
27
|
+
# -------------------------------
|
|
28
|
+
def validate_token(access_token)
|
|
29
|
+
return if access_token.blank?
|
|
24
30
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
@current_access_token = tokens[:access_token]
|
|
29
|
-
@current_refresh_token = tokens[:refresh_token]
|
|
30
|
-
tokens
|
|
31
|
+
TokenStore.authenticate(access_token)
|
|
32
|
+
rescue
|
|
33
|
+
nil
|
|
31
34
|
end
|
|
32
35
|
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
# -------------------------------
|
|
37
|
+
# ROTATE REFRESH TOKEN
|
|
38
|
+
# -------------------------------
|
|
39
|
+
def rotate_tokens(refresh_token)
|
|
40
|
+
return if refresh_token.blank?
|
|
37
41
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
@current_access_token = nil
|
|
42
|
-
@current_refresh_token = nil
|
|
42
|
+
TokenStore.renew_tokens(refresh_token)
|
|
43
|
+
rescue
|
|
44
|
+
nil
|
|
43
45
|
end
|
|
44
46
|
|
|
45
|
-
#
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
# -------------------------------
|
|
48
|
+
# LOGOUT CURRENT SESSION
|
|
49
|
+
# -------------------------------
|
|
50
|
+
def logout(access_token)
|
|
51
|
+
return if access_token.blank?
|
|
52
|
+
|
|
53
|
+
TokenStore.revoke_current_session(access_token)
|
|
54
|
+
rescue
|
|
55
|
+
nil
|
|
48
56
|
end
|
|
57
|
+
end
|
|
49
58
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
# Returns the record if tokens valid, else nil
|
|
55
|
-
#
|
|
56
|
-
def self.login(access_token, refresh_token = nil)
|
|
57
|
-
record = TokenStore.fetch_record_from_access(access_token)
|
|
58
|
-
return nil unless record
|
|
59
|
-
|
|
60
|
-
if refresh_token
|
|
61
|
-
refresh_record = TokenStore.fetch_record_from_refresh(refresh_token)
|
|
62
|
-
return nil unless refresh_record == record
|
|
63
|
-
end
|
|
59
|
+
# ============================================================
|
|
60
|
+
# INSTANCE METHODS
|
|
61
|
+
# ============================================================
|
|
62
|
+
module InstanceMethods
|
|
64
63
|
|
|
65
|
-
|
|
64
|
+
attr_reader :current_access_token, :current_refresh_token
|
|
65
|
+
|
|
66
|
+
# -------------------------------
|
|
67
|
+
# LOGIN / ISSUE TOKENS
|
|
68
|
+
# -------------------------------
|
|
69
|
+
def generate_tokens
|
|
70
|
+
raise "Record must be persisted" unless persisted?
|
|
71
|
+
|
|
72
|
+
tokens = TokenStore.issue_tokens(self)
|
|
73
|
+
|
|
74
|
+
assign_tokens(tokens) if tokens.present?
|
|
75
|
+
|
|
76
|
+
tokens
|
|
77
|
+
rescue
|
|
78
|
+
nil
|
|
66
79
|
end
|
|
67
80
|
|
|
68
|
-
#
|
|
69
|
-
|
|
70
|
-
|
|
81
|
+
# -------------------------------
|
|
82
|
+
# LOGOUT ALL DEVICES
|
|
83
|
+
# -------------------------------
|
|
84
|
+
def logout_all
|
|
85
|
+
return unless persisted?
|
|
86
|
+
|
|
87
|
+
TokenStore.revoke_all(self)
|
|
88
|
+
rescue
|
|
89
|
+
nil
|
|
71
90
|
end
|
|
72
91
|
|
|
73
92
|
private
|
|
74
93
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
94
|
+
# ============================================================
|
|
95
|
+
# PASSWORD CHANGE DETECTION
|
|
96
|
+
# ============================================================
|
|
97
|
+
def _tokenzen_revoke_on_password_change
|
|
98
|
+
return unless _password_changed?
|
|
99
|
+
|
|
100
|
+
TokenStore.revoke_all(self)
|
|
101
|
+
rescue
|
|
102
|
+
nil
|
|
78
103
|
end
|
|
79
104
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
105
|
+
# Support both has_secure_password and custom columns
|
|
106
|
+
def _password_changed?
|
|
107
|
+
if respond_to?(:saved_change_to_password_digest?)
|
|
108
|
+
saved_change_to_password_digest?
|
|
109
|
+
elsif respond_to?(:saved_change_to_encrypted_password?)
|
|
110
|
+
saved_change_to_encrypted_password?
|
|
111
|
+
else
|
|
112
|
+
false
|
|
113
|
+
end
|
|
86
114
|
end
|
|
87
115
|
|
|
88
|
-
|
|
89
|
-
|
|
116
|
+
# ============================================================
|
|
117
|
+
# DESTROY CALLBACK
|
|
118
|
+
# ============================================================
|
|
119
|
+
def _tokenzen_revoke_all_on_destroy
|
|
120
|
+
TokenStore.revoke_all(self)
|
|
121
|
+
rescue
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# ============================================================
|
|
126
|
+
# ASSIGN TOKENS TO INSTANCE
|
|
127
|
+
# ============================================================
|
|
128
|
+
def assign_tokens(tokens)
|
|
129
|
+
@current_access_token = tokens[:access_token]
|
|
130
|
+
@current_refresh_token = tokens[:refresh_token]
|
|
90
131
|
end
|
|
91
132
|
end
|
|
92
133
|
end
|
|
93
|
-
end
|
|
134
|
+
end
|
|
@@ -1,52 +1,80 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
4
|
-
require "
|
|
5
|
-
require "
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "digest"
|
|
6
6
|
|
|
7
7
|
module Tokenzen
|
|
8
8
|
class Configuration
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
class << self
|
|
10
|
+
attr_accessor :cache_store,
|
|
11
|
+
:access_token_expiry,
|
|
12
|
+
:refresh_token_expiry,
|
|
13
|
+
:secret_key,
|
|
14
|
+
:max_sessions
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
# ================================
|
|
17
|
+
# Default Setup
|
|
18
|
+
# ================================
|
|
19
|
+
def setup!
|
|
20
|
+
@cache_store ||= defined?(Rails) ? Rails.cache : {}
|
|
21
|
+
@access_token_expiry ||= 172800 # 2 days in seconds
|
|
22
|
+
@refresh_token_expiry ||= 5_184_000 # 60 days
|
|
23
|
+
@max_sessions = 3
|
|
24
|
+
@secret_key ||= default_secret_key
|
|
25
|
+
end
|
|
18
26
|
|
|
19
|
-
|
|
20
|
-
|
|
27
|
+
# ================================
|
|
28
|
+
# AES-256-GCM Encryption
|
|
29
|
+
# ================================
|
|
30
|
+
def encrypt(value)
|
|
31
|
+
setup!
|
|
21
32
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
33
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
34
|
+
cipher.encrypt
|
|
35
|
+
cipher.key = derived_key
|
|
36
|
+
iv = cipher.random_iv
|
|
26
37
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
38
|
+
encrypted = cipher.update(value.to_s) + cipher.final
|
|
39
|
+
tag = cipher.auth_tag
|
|
40
|
+
|
|
41
|
+
Base64.strict_encode64(iv + tag + encrypted)
|
|
42
|
+
end
|
|
31
43
|
|
|
32
|
-
|
|
44
|
+
def decrypt(value)
|
|
45
|
+
setup!
|
|
33
46
|
|
|
34
|
-
|
|
35
|
-
@message_encryptor ||= begin
|
|
36
|
-
key_len = ActiveSupport::MessageEncryptor.key_len
|
|
37
|
-
generator = ActiveSupport::KeyGenerator.new(@secret_key)
|
|
38
|
-
key = generator.generate_key("tokenzen", key_len)
|
|
47
|
+
decoded = Base64.strict_decode64(value)
|
|
39
48
|
|
|
40
|
-
|
|
49
|
+
iv = decoded[0..11]
|
|
50
|
+
tag = decoded[12..27]
|
|
51
|
+
encrypted = decoded[28..-1]
|
|
52
|
+
|
|
53
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
54
|
+
cipher.decrypt
|
|
55
|
+
cipher.key = derived_key
|
|
56
|
+
cipher.iv = iv
|
|
57
|
+
cipher.auth_tag = tag
|
|
58
|
+
|
|
59
|
+
cipher.update(encrypted) + cipher.final
|
|
60
|
+
rescue OpenSSL::Cipher::CipherError, ArgumentError
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def derived_key
|
|
67
|
+
Digest::SHA256.digest(secret_key)
|
|
41
68
|
end
|
|
42
|
-
end
|
|
43
69
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
70
|
+
def default_secret_key
|
|
71
|
+
if defined?(Rails) && Rails.application.respond_to?(:secret_key_base)
|
|
72
|
+
Rails.application.secret_key_base
|
|
73
|
+
else
|
|
74
|
+
ENV["TOKENZEN_SECRET_KEY"] ||
|
|
75
|
+
raise("TOKENZEN_SECRET_KEY not set and Rails.secret_key_base not found")
|
|
76
|
+
end
|
|
49
77
|
end
|
|
50
78
|
end
|
|
51
79
|
end
|
|
52
|
-
end
|
|
80
|
+
end
|
data/lib/tokenzen/token_store.rb
CHANGED
|
@@ -1,102 +1,282 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
3
5
|
module Tokenzen
|
|
4
6
|
class TokenStore
|
|
5
7
|
class << self
|
|
6
8
|
|
|
7
|
-
#
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
# ============================================================
|
|
10
|
+
# ISSUE TOKENS (LOGIN / NEW DEVICE SESSION)
|
|
11
|
+
# ============================================================
|
|
12
|
+
def issue_tokens(record)
|
|
13
|
+
raise ArgumentError, "Record must be persisted" unless record&.id
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
refresh_key = SecureRandom.hex(32)
|
|
15
|
+
enforce_session_limit(record)
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
"model" => record.class.name,
|
|
17
|
-
"id" => record.id,
|
|
18
|
-
"type" => "access"
|
|
19
|
-
}
|
|
17
|
+
session_id = SecureRandom.uuid
|
|
20
18
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
19
|
+
access_raw = SecureRandom.hex(64)
|
|
20
|
+
refresh_raw = SecureRandom.hex(64)
|
|
21
|
+
|
|
22
|
+
access_payload = build_payload(record, session_id, "access")
|
|
23
|
+
refresh_payload = build_payload(record, session_id, "refresh")
|
|
26
24
|
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
write_token(access_raw, access_payload, config.access_token_expiry)
|
|
26
|
+
write_token(refresh_raw, refresh_payload, config.refresh_token_expiry)
|
|
29
27
|
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
store_session_mapping(record, session_id, access_raw, refresh_raw)
|
|
29
|
+
|
|
30
|
+
register_session(record, session_id)
|
|
32
31
|
|
|
33
32
|
{
|
|
34
|
-
access_token:
|
|
35
|
-
refresh_token: encrypt(
|
|
33
|
+
access_token: encrypt(access_raw),
|
|
34
|
+
refresh_token: encrypt(refresh_raw)
|
|
36
35
|
}
|
|
37
36
|
end
|
|
38
37
|
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
38
|
+
# ============================================================
|
|
39
|
+
# AUTHENTICATE ACCESS TOKEN
|
|
40
|
+
# ============================================================
|
|
41
|
+
def authenticate(encrypted_token)
|
|
42
|
+
raw = safe_decrypt(encrypted_token)
|
|
43
|
+
return unless raw
|
|
44
44
|
|
|
45
|
-
payload
|
|
45
|
+
payload = safe_read(raw)
|
|
46
|
+
return unless valid_payload?(payload, "access")
|
|
47
|
+
|
|
48
|
+
record = safe_find_record(payload)
|
|
49
|
+
return unless record
|
|
50
|
+
|
|
51
|
+
# ensure session still valid
|
|
52
|
+
return unless session_exists?(record, payload["session_id"])
|
|
53
|
+
|
|
54
|
+
record
|
|
46
55
|
end
|
|
47
56
|
|
|
48
|
-
#
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
# ============================================================
|
|
58
|
+
# REFRESH TOKEN (ROTATION)
|
|
59
|
+
# ============================================================
|
|
60
|
+
def renew_tokens(encrypted_refresh_token)
|
|
61
|
+
raw = safe_decrypt(encrypted_refresh_token)
|
|
62
|
+
return unless raw
|
|
53
63
|
|
|
54
|
-
payload
|
|
64
|
+
payload = safe_read(raw)
|
|
65
|
+
return unless valid_payload?(payload, "refresh")
|
|
66
|
+
|
|
67
|
+
record = safe_find_record(payload)
|
|
68
|
+
return unless record
|
|
69
|
+
|
|
70
|
+
session_id = payload["session_id"]
|
|
71
|
+
|
|
72
|
+
return unless session_exists?(record, session_id)
|
|
73
|
+
|
|
74
|
+
# delete old refresh token immediately (rotation)
|
|
75
|
+
cache.delete(raw)
|
|
76
|
+
|
|
77
|
+
revoke_session(record, session_id)
|
|
78
|
+
|
|
79
|
+
issue_tokens(record)
|
|
55
80
|
end
|
|
56
81
|
|
|
57
|
-
#
|
|
58
|
-
|
|
59
|
-
|
|
82
|
+
# ============================================================
|
|
83
|
+
# LOGOUT CURRENT SESSION (THIS DEVICE ONLY)
|
|
84
|
+
# ============================================================
|
|
85
|
+
def revoke_current_session(encrypted_access_token)
|
|
86
|
+
raw = safe_decrypt(encrypted_access_token)
|
|
87
|
+
return unless raw
|
|
88
|
+
|
|
89
|
+
payload = safe_read(raw)
|
|
90
|
+
return unless valid_payload?(payload, "access")
|
|
91
|
+
|
|
92
|
+
record = safe_find_record(payload)
|
|
60
93
|
return unless record
|
|
61
94
|
|
|
62
|
-
|
|
63
|
-
clear_tokens(record) # clear old tokens
|
|
64
|
-
tokens
|
|
95
|
+
revoke_session(record, payload["session_id"])
|
|
65
96
|
end
|
|
66
97
|
|
|
67
|
-
#
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
98
|
+
# ============================================================
|
|
99
|
+
# LOGOUT ALL SESSIONS
|
|
100
|
+
# ============================================================
|
|
101
|
+
def revoke_all(record)
|
|
102
|
+
return unless record&.id
|
|
103
|
+
|
|
104
|
+
sessions(record).dup.each do |session_id|
|
|
105
|
+
revoke_session(record, session_id)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
cache.delete(user_key(record))
|
|
71
109
|
end
|
|
72
110
|
|
|
111
|
+
# ============================================================
|
|
112
|
+
# PRIVATE
|
|
113
|
+
# ============================================================
|
|
73
114
|
private
|
|
74
115
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
116
|
+
# -------------------------------
|
|
117
|
+
# TOKEN WRITE
|
|
118
|
+
# -------------------------------
|
|
119
|
+
def write_token(raw_key, payload, expiry)
|
|
120
|
+
cache.write(raw_key, payload, expires_in: expiry)
|
|
121
|
+
rescue => _e
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# -------------------------------
|
|
126
|
+
# BUILD PAYLOAD
|
|
127
|
+
# -------------------------------
|
|
128
|
+
def build_payload(record, session_id, type)
|
|
129
|
+
{
|
|
130
|
+
"model" => record.class.name,
|
|
131
|
+
"id" => record.id,
|
|
132
|
+
"type" => type,
|
|
133
|
+
"session_id" => session_id
|
|
134
|
+
}
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# -------------------------------
|
|
138
|
+
# SAFE CACHE READ
|
|
139
|
+
# -------------------------------
|
|
140
|
+
def safe_read(key)
|
|
141
|
+
cache.read(key)
|
|
142
|
+
rescue
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# -------------------------------
|
|
147
|
+
# SAFE DECRYPT
|
|
148
|
+
# -------------------------------
|
|
149
|
+
def safe_decrypt(token)
|
|
150
|
+
decrypt(token)
|
|
151
|
+
rescue
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# -------------------------------
|
|
156
|
+
# VALIDATE PAYLOAD
|
|
157
|
+
# -------------------------------
|
|
158
|
+
def valid_payload?(payload, expected_type)
|
|
159
|
+
payload &&
|
|
160
|
+
payload["type"] == expected_type &&
|
|
161
|
+
payload["model"].present? &&
|
|
162
|
+
payload["id"].present? &&
|
|
163
|
+
payload["session_id"].present?
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# -------------------------------
|
|
167
|
+
# SAFE FIND RECORD
|
|
168
|
+
# -------------------------------
|
|
169
|
+
def safe_find_record(payload)
|
|
170
|
+
payload["model"].constantize.find_by(id: payload["id"])
|
|
171
|
+
rescue
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# -------------------------------
|
|
176
|
+
# SESSION LIMIT ENFORCEMENT
|
|
177
|
+
# -------------------------------
|
|
178
|
+
def enforce_session_limit(record)
|
|
179
|
+
list = sessions(record)
|
|
180
|
+
|
|
181
|
+
return if list.size < config.max_sessions
|
|
182
|
+
|
|
183
|
+
oldest = list.first
|
|
184
|
+
revoke_session(record, oldest)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# -------------------------------
|
|
188
|
+
# REGISTER SESSION
|
|
189
|
+
# -------------------------------
|
|
190
|
+
def register_session(record, session_id)
|
|
191
|
+
list = sessions(record)
|
|
192
|
+
list << session_id
|
|
193
|
+
|
|
194
|
+
cache.write(
|
|
195
|
+
user_key(record),
|
|
196
|
+
list,
|
|
197
|
+
expires_in: config.refresh_token_expiry
|
|
198
|
+
)
|
|
199
|
+
rescue
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# -------------------------------
|
|
204
|
+
# STORE SESSION TOKEN MAPPING
|
|
205
|
+
# -------------------------------
|
|
206
|
+
def store_session_mapping(record, session_id, access_raw, refresh_raw)
|
|
207
|
+
cache.write(
|
|
208
|
+
session_token_key(record, session_id),
|
|
209
|
+
{ access: access_raw, refresh: refresh_raw },
|
|
210
|
+
expires_in: config.refresh_token_expiry
|
|
211
|
+
)
|
|
212
|
+
rescue
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# -------------------------------
|
|
217
|
+
# REVOKE ONE SESSION
|
|
218
|
+
# -------------------------------
|
|
219
|
+
def revoke_session(record, session_id)
|
|
220
|
+
session_data = cache.read(session_token_key(record, session_id))
|
|
221
|
+
return unless session_data
|
|
222
|
+
|
|
223
|
+
cache.delete(session_data[:access])
|
|
224
|
+
cache.delete(session_data[:refresh])
|
|
225
|
+
cache.delete(session_token_key(record, session_id))
|
|
226
|
+
|
|
227
|
+
list = sessions(record)
|
|
228
|
+
list.delete(session_id)
|
|
229
|
+
|
|
230
|
+
cache.write(user_key(record), list)
|
|
231
|
+
rescue
|
|
232
|
+
nil
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# -------------------------------
|
|
236
|
+
# CHECK SESSION EXISTS
|
|
237
|
+
# -------------------------------
|
|
238
|
+
def session_exists?(record, session_id)
|
|
239
|
+
sessions(record).include?(session_id)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# -------------------------------
|
|
243
|
+
# FETCH SESSIONS
|
|
244
|
+
# -------------------------------
|
|
245
|
+
def sessions(record)
|
|
246
|
+
cache.read(user_key(record)) || []
|
|
247
|
+
rescue
|
|
248
|
+
[]
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# -------------------------------
|
|
252
|
+
# CACHE KEYS
|
|
253
|
+
# -------------------------------
|
|
254
|
+
def user_key(record)
|
|
255
|
+
"tokenzen:#{record.class.name}:#{record.id}:sessions"
|
|
79
256
|
end
|
|
80
257
|
|
|
81
|
-
def
|
|
82
|
-
|
|
258
|
+
def session_token_key(record, session_id)
|
|
259
|
+
"tokenzen:#{record.class.name}:#{record.id}:#{session_id}"
|
|
83
260
|
end
|
|
84
261
|
|
|
85
|
-
|
|
86
|
-
|
|
262
|
+
# -------------------------------
|
|
263
|
+
# HELPERS
|
|
264
|
+
# -------------------------------
|
|
265
|
+
def config
|
|
266
|
+
Tokenzen.configuration
|
|
87
267
|
end
|
|
88
268
|
|
|
89
269
|
def cache
|
|
90
|
-
|
|
270
|
+
config.cache_store
|
|
91
271
|
end
|
|
92
272
|
|
|
93
273
|
def encrypt(value)
|
|
94
|
-
|
|
274
|
+
config.encrypt(value)
|
|
95
275
|
end
|
|
96
276
|
|
|
97
277
|
def decrypt(value)
|
|
98
|
-
|
|
278
|
+
config.decrypt(value)
|
|
99
279
|
end
|
|
100
280
|
end
|
|
101
281
|
end
|
|
102
|
-
end
|
|
282
|
+
end
|
data/lib/tokenzen/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tokenzen
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Satendra
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-02-
|
|
11
|
+
date: 2026-02-25 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -52,7 +52,6 @@ files:
|
|
|
52
52
|
- lib/tokenzen.rb
|
|
53
53
|
- lib/tokenzen/authenticatable.rb
|
|
54
54
|
- lib/tokenzen/configuration.rb
|
|
55
|
-
- lib/tokenzen/model.rb
|
|
56
55
|
- lib/tokenzen/railtie.rb
|
|
57
56
|
- lib/tokenzen/token_store.rb
|
|
58
57
|
- lib/tokenzen/version.rb
|
data/lib/tokenzen/model.rb
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Tokenzen
|
|
4
|
-
module Model
|
|
5
|
-
extend ActiveSupport::Concern
|
|
6
|
-
|
|
7
|
-
included do
|
|
8
|
-
attr_reader :current_access_token
|
|
9
|
-
|
|
10
|
-
before_save :renew_refresh_token_on_password_change
|
|
11
|
-
around_save :handle_refresh_token_rotation
|
|
12
|
-
after_destroy :clear_all_access_tokens
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
# PUBLIC API
|
|
16
|
-
def generate_access_token
|
|
17
|
-
token = Tokenzen::TokenStore.write(id)
|
|
18
|
-
return unless token
|
|
19
|
-
|
|
20
|
-
Tokenzen::TokenStore.store_user_token(id, token)
|
|
21
|
-
@current_access_token = token
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def clear_all_access_tokens
|
|
25
|
-
Tokenzen::TokenStore.clear_user_tokens(id)
|
|
26
|
-
@current_access_token = nil
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
private
|
|
30
|
-
|
|
31
|
-
def renew_refresh_token_on_password_change
|
|
32
|
-
return unless respond_to?(:password_digest_changed?) && password_digest_changed?
|
|
33
|
-
|
|
34
|
-
self.refresh_token = JwtProcessor.encode(id: id)
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def handle_refresh_token_rotation
|
|
38
|
-
changed = respond_to?(:refresh_token_changed?) && refresh_token_changed?
|
|
39
|
-
yield
|
|
40
|
-
return unless changed
|
|
41
|
-
|
|
42
|
-
clear_all_access_tokens
|
|
43
|
-
generate_access_token
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
end
|