tina4ruby 3.11.14 → 3.11.15
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 +80 -80
- data/LICENSE.txt +21 -21
- data/README.md +137 -137
- data/exe/tina4ruby +5 -5
- data/lib/tina4/ai.rb +696 -696
- data/lib/tina4/api.rb +189 -189
- data/lib/tina4/auth.rb +305 -305
- data/lib/tina4/auto_crud.rb +244 -244
- data/lib/tina4/cache.rb +154 -154
- data/lib/tina4/cli.rb +1449 -1449
- data/lib/tina4/constants.rb +46 -46
- data/lib/tina4/container.rb +74 -74
- data/lib/tina4/cors.rb +74 -74
- data/lib/tina4/crud.rb +692 -692
- data/lib/tina4/database/sqlite3_adapter.rb +165 -165
- data/lib/tina4/database.rb +625 -625
- data/lib/tina4/database_result.rb +208 -208
- data/lib/tina4/debug.rb +8 -8
- data/lib/tina4/dev.rb +14 -14
- data/lib/tina4/dev_admin.rb +935 -935
- data/lib/tina4/dev_mailbox.rb +191 -191
- data/lib/tina4/drivers/firebird_driver.rb +124 -124
- data/lib/tina4/drivers/mongodb_driver.rb +561 -561
- data/lib/tina4/drivers/mssql_driver.rb +112 -112
- data/lib/tina4/drivers/mysql_driver.rb +90 -90
- data/lib/tina4/drivers/odbc_driver.rb +191 -191
- data/lib/tina4/drivers/postgres_driver.rb +116 -116
- data/lib/tina4/drivers/sqlite_driver.rb +122 -122
- data/lib/tina4/env.rb +95 -95
- data/lib/tina4/error_overlay.rb +252 -252
- data/lib/tina4/events.rb +109 -109
- data/lib/tina4/field_types.rb +154 -154
- data/lib/tina4/frond.rb +2025 -2025
- data/lib/tina4/gallery/auth/meta.json +1 -1
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
- data/lib/tina4/gallery/database/meta.json +1 -1
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
- data/lib/tina4/gallery/error-overlay/meta.json +1 -1
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
- data/lib/tina4/gallery/orm/meta.json +1 -1
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
- data/lib/tina4/gallery/queue/meta.json +1 -1
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
- data/lib/tina4/gallery/rest-api/meta.json +1 -1
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
- data/lib/tina4/gallery/templates/meta.json +1 -1
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
- data/lib/tina4/graphql.rb +966 -966
- data/lib/tina4/health.rb +39 -39
- data/lib/tina4/html_element.rb +170 -170
- data/lib/tina4/job.rb +80 -80
- data/lib/tina4/localization.rb +168 -168
- data/lib/tina4/log.rb +203 -203
- data/lib/tina4/mcp.rb +696 -696
- data/lib/tina4/messenger.rb +587 -587
- data/lib/tina4/metrics.rb +793 -793
- data/lib/tina4/middleware.rb +445 -445
- data/lib/tina4/migration.rb +451 -451
- data/lib/tina4/orm.rb +790 -790
- data/lib/tina4/public/css/tina4.css +2463 -2463
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/public/images/logo.svg +5 -5
- data/lib/tina4/public/js/frond.min.js +2 -2
- data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
- data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
- data/lib/tina4/public/js/tina4.min.js +92 -92
- data/lib/tina4/public/js/tina4js.min.js +48 -48
- data/lib/tina4/public/swagger/index.html +90 -90
- data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
- data/lib/tina4/query_builder.rb +380 -380
- data/lib/tina4/queue.rb +366 -366
- data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
- data/lib/tina4/queue_backends/lite_backend.rb +298 -298
- data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
- data/lib/tina4/rack_app.rb +817 -817
- data/lib/tina4/rate_limiter.rb +130 -130
- data/lib/tina4/request.rb +268 -255
- data/lib/tina4/response.rb +346 -346
- data/lib/tina4/response_cache.rb +551 -551
- data/lib/tina4/router.rb +406 -406
- data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
- data/lib/tina4/scss/tina4css/_badges.scss +22 -22
- data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
- data/lib/tina4/scss/tina4css/_cards.scss +49 -49
- data/lib/tina4/scss/tina4css/_forms.scss +156 -156
- data/lib/tina4/scss/tina4css/_grid.scss +81 -81
- data/lib/tina4/scss/tina4css/_modals.scss +84 -84
- data/lib/tina4/scss/tina4css/_nav.scss +149 -149
- data/lib/tina4/scss/tina4css/_reset.scss +94 -94
- data/lib/tina4/scss/tina4css/_tables.scss +54 -54
- data/lib/tina4/scss/tina4css/_typography.scss +55 -55
- data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
- data/lib/tina4/scss/tina4css/_variables.scss +117 -117
- data/lib/tina4/scss/tina4css/base.scss +1 -1
- data/lib/tina4/scss/tina4css/colors.scss +48 -48
- data/lib/tina4/scss/tina4css/tina4.scss +17 -17
- data/lib/tina4/scss_compiler.rb +178 -178
- data/lib/tina4/seeder.rb +567 -567
- data/lib/tina4/service_runner.rb +303 -303
- data/lib/tina4/session.rb +297 -297
- data/lib/tina4/session_handlers/database_handler.rb +72 -72
- data/lib/tina4/session_handlers/file_handler.rb +67 -67
- data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
- data/lib/tina4/session_handlers/redis_handler.rb +43 -43
- data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
- data/lib/tina4/shutdown.rb +84 -84
- data/lib/tina4/sql_translation.rb +158 -158
- data/lib/tina4/swagger.rb +124 -124
- data/lib/tina4/template.rb +894 -894
- data/lib/tina4/templates/base.twig +26 -26
- data/lib/tina4/templates/errors/302.twig +14 -14
- data/lib/tina4/templates/errors/401.twig +9 -9
- data/lib/tina4/templates/errors/403.twig +29 -29
- data/lib/tina4/templates/errors/404.twig +29 -29
- data/lib/tina4/templates/errors/500.twig +38 -38
- data/lib/tina4/templates/errors/502.twig +9 -9
- data/lib/tina4/templates/errors/503.twig +12 -12
- data/lib/tina4/templates/errors/base.twig +37 -37
- data/lib/tina4/test_client.rb +159 -159
- data/lib/tina4/testing.rb +340 -340
- data/lib/tina4/validator.rb +174 -174
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +312 -312
- data/lib/tina4/websocket.rb +343 -343
- data/lib/tina4/websocket_backplane.rb +190 -190
- data/lib/tina4/wsdl.rb +564 -564
- data/lib/tina4.rb +458 -458
- data/lib/tina4ruby.rb +4 -4
- metadata +2 -2
data/lib/tina4/auth.rb
CHANGED
|
@@ -1,305 +1,305 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
require "openssl"
|
|
3
|
-
require "base64"
|
|
4
|
-
require "json"
|
|
5
|
-
require "fileutils"
|
|
6
|
-
|
|
7
|
-
module Tina4
|
|
8
|
-
module Auth
|
|
9
|
-
KEYS_DIR = ".keys"
|
|
10
|
-
|
|
11
|
-
class << self
|
|
12
|
-
def setup(root_dir = Dir.pwd)
|
|
13
|
-
@keys_dir = File.join(root_dir, KEYS_DIR)
|
|
14
|
-
FileUtils.mkdir_p(@keys_dir)
|
|
15
|
-
ensure_keys
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
# ── HS256 helpers (stdlib only, no gem) ──────────────────────
|
|
19
|
-
|
|
20
|
-
# Returns true when SECRET env var is set and no RSA keys exist in .keys/
|
|
21
|
-
def use_hmac?
|
|
22
|
-
secret = ENV["SECRET"]
|
|
23
|
-
return false if secret.nil? || secret.empty?
|
|
24
|
-
|
|
25
|
-
# If RSA keys already exist on disk, prefer RS256 for backward compat
|
|
26
|
-
@keys_dir ||= File.join(Dir.pwd, KEYS_DIR)
|
|
27
|
-
!(File.exist?(File.join(@keys_dir, "private.pem")) &&
|
|
28
|
-
File.exist?(File.join(@keys_dir, "public.pem")))
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def hmac_secret
|
|
32
|
-
ENV["SECRET"]
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
# Base64url-encode without padding (JWT spec)
|
|
36
|
-
def base64url_encode(data)
|
|
37
|
-
Base64.urlsafe_encode64(data, padding: false)
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
# Base64url-decode (handles missing padding)
|
|
41
|
-
def base64url_decode(str)
|
|
42
|
-
# Add back padding
|
|
43
|
-
remainder = str.length % 4
|
|
44
|
-
str += "=" * ((4 - remainder) % 4) if remainder != 0
|
|
45
|
-
Base64.urlsafe_decode64(str)
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# Build a JWT using HS256 with Ruby's OpenSSL::HMAC (no gem needed)
|
|
49
|
-
def hmac_encode(claims, secret)
|
|
50
|
-
header = { "alg" => "HS256", "typ" => "JWT" }
|
|
51
|
-
segments = [
|
|
52
|
-
base64url_encode(JSON.generate(header)),
|
|
53
|
-
base64url_encode(JSON.generate(claims))
|
|
54
|
-
]
|
|
55
|
-
signing_input = segments.join(".")
|
|
56
|
-
signature = OpenSSL::HMAC.digest("SHA256", secret, signing_input)
|
|
57
|
-
segments << base64url_encode(signature)
|
|
58
|
-
segments.join(".")
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# Decode and verify a JWT signed with HS256. Returns the payload hash or nil.
|
|
62
|
-
def hmac_decode(token, secret)
|
|
63
|
-
parts = token.split(".")
|
|
64
|
-
return nil unless parts.length == 3
|
|
65
|
-
|
|
66
|
-
header_json = base64url_decode(parts[0])
|
|
67
|
-
header = JSON.parse(header_json)
|
|
68
|
-
return nil unless header["alg"] == "HS256"
|
|
69
|
-
|
|
70
|
-
# Verify signature
|
|
71
|
-
signing_input = "#{parts[0]}.#{parts[1]}"
|
|
72
|
-
expected_sig = OpenSSL::HMAC.digest("SHA256", secret, signing_input)
|
|
73
|
-
actual_sig = base64url_decode(parts[2])
|
|
74
|
-
|
|
75
|
-
# Constant-time comparison to prevent timing attacks
|
|
76
|
-
return nil unless OpenSSL.fixed_length_secure_compare(expected_sig, actual_sig)
|
|
77
|
-
|
|
78
|
-
payload = JSON.parse(base64url_decode(parts[1]))
|
|
79
|
-
|
|
80
|
-
# Check expiry
|
|
81
|
-
now = Time.now.to_i
|
|
82
|
-
return nil if payload["exp"] && now >= payload["exp"]
|
|
83
|
-
return nil if payload["nbf"] && now < payload["nbf"]
|
|
84
|
-
|
|
85
|
-
payload
|
|
86
|
-
rescue ArgumentError, JSON::ParserError, OpenSSL::HMACError
|
|
87
|
-
nil
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
# ── Token API (auto-selects HS256 or RS256) ─────────────────
|
|
91
|
-
|
|
92
|
-
def get_token(payload, expires_in: 60, secret: nil)
|
|
93
|
-
now = Time.now.to_i
|
|
94
|
-
claims = payload.merge(
|
|
95
|
-
"iat" => now,
|
|
96
|
-
"exp" => now + (expires_in * 60).to_i,
|
|
97
|
-
"nbf" => now
|
|
98
|
-
)
|
|
99
|
-
|
|
100
|
-
if secret
|
|
101
|
-
hmac_encode(claims, secret)
|
|
102
|
-
elsif use_hmac?
|
|
103
|
-
hmac_encode(claims, hmac_secret)
|
|
104
|
-
else
|
|
105
|
-
ensure_keys
|
|
106
|
-
require "jwt"
|
|
107
|
-
JWT.encode(claims, private_key, "RS256")
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
def valid_token(token) # -> bool
|
|
113
|
-
if use_hmac?
|
|
114
|
-
!hmac_decode(token, hmac_secret).nil?
|
|
115
|
-
else
|
|
116
|
-
ensure_keys
|
|
117
|
-
require "jwt"
|
|
118
|
-
JWT.decode(token, public_key, true, algorithm: "RS256")
|
|
119
|
-
true
|
|
120
|
-
end
|
|
121
|
-
rescue JWT::ExpiredSignature
|
|
122
|
-
false
|
|
123
|
-
rescue JWT::DecodeError
|
|
124
|
-
false
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def valid_token_detail(token)
|
|
128
|
-
if use_hmac?
|
|
129
|
-
payload = hmac_decode(token, hmac_secret)
|
|
130
|
-
if payload
|
|
131
|
-
{ valid: true, payload: payload }
|
|
132
|
-
else
|
|
133
|
-
{ valid: false, error: "Invalid or expired token" }
|
|
134
|
-
end
|
|
135
|
-
else
|
|
136
|
-
ensure_keys
|
|
137
|
-
require "jwt"
|
|
138
|
-
decoded = JWT.decode(token, public_key, true, algorithm: "RS256")
|
|
139
|
-
{ valid: true, payload: decoded[0] }
|
|
140
|
-
end
|
|
141
|
-
rescue JWT::ExpiredSignature
|
|
142
|
-
{ valid: false, error: "Token expired" }
|
|
143
|
-
rescue JWT::DecodeError => e
|
|
144
|
-
{ valid: false, error: e.message }
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
def hash_password(password, salt = nil, iterations = 260000)
|
|
148
|
-
salt ||= SecureRandom.hex(16)
|
|
149
|
-
dk = OpenSSL::KDF.pbkdf2_hmac(password, salt: salt, iterations: iterations, length: 32, hash: "sha256")
|
|
150
|
-
"pbkdf2_sha256$#{iterations}$#{salt}$#{dk.unpack1('H*')}"
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def check_password(password, hash)
|
|
154
|
-
parts = hash.split('$')
|
|
155
|
-
return false unless parts.length == 4 && parts[0] == 'pbkdf2_sha256'
|
|
156
|
-
iterations = parts[1].to_i
|
|
157
|
-
salt = parts[2]
|
|
158
|
-
expected = parts[3]
|
|
159
|
-
dk = OpenSSL::KDF.pbkdf2_hmac(password, salt: salt, iterations: iterations, length: 32, hash: "sha256")
|
|
160
|
-
actual = dk.unpack1('H*')
|
|
161
|
-
# Timing-safe comparison
|
|
162
|
-
OpenSSL.fixed_length_secure_compare(actual, expected)
|
|
163
|
-
rescue
|
|
164
|
-
false
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
def get_payload(token)
|
|
169
|
-
parts = token.split(".")
|
|
170
|
-
return nil unless parts.length == 3
|
|
171
|
-
|
|
172
|
-
payload_json = base64url_decode(parts[1])
|
|
173
|
-
JSON.parse(payload_json)
|
|
174
|
-
rescue ArgumentError, JSON::ParserError
|
|
175
|
-
nil
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
def refresh_token(token, expires_in: 60)
|
|
179
|
-
return nil unless valid_token(token)
|
|
180
|
-
|
|
181
|
-
payload = get_payload(token)
|
|
182
|
-
return nil unless payload
|
|
183
|
-
payload = payload.reject { |k, _| %w[iat exp nbf].include?(k) }
|
|
184
|
-
get_token(payload, expires_in: expires_in)
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
def authenticate_request(headers, secret: nil, algorithm: "HS256")
|
|
188
|
-
auth_header = headers["HTTP_AUTHORIZATION"] || headers["Authorization"] || ""
|
|
189
|
-
return nil unless auth_header =~ /\ABearer\s+(.+)\z/i
|
|
190
|
-
|
|
191
|
-
token = Regexp.last_match(1)
|
|
192
|
-
|
|
193
|
-
# API_KEY bypass — matches tina4_python behavior
|
|
194
|
-
api_key = ENV["TINA4_API_KEY"] || ENV["API_KEY"]
|
|
195
|
-
if api_key && !api_key.empty? && token == api_key
|
|
196
|
-
return { "api_key" => true }
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
# If a custom secret is provided, validate against it directly
|
|
200
|
-
if secret
|
|
201
|
-
payload = hmac_decode(token, secret)
|
|
202
|
-
return payload ? payload : nil
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
valid_token(token) ? get_payload(token) : nil
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
def validate_api_key(provided, expected: nil)
|
|
209
|
-
expected ||= ENV["TINA4_API_KEY"] || ENV["API_KEY"]
|
|
210
|
-
return false if expected.nil? || expected.empty?
|
|
211
|
-
return false if provided.nil? || provided.empty?
|
|
212
|
-
return false if provided.length != expected.length
|
|
213
|
-
|
|
214
|
-
OpenSSL.fixed_length_secure_compare(provided, expected)
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
def auth_handler(&block)
|
|
218
|
-
if block_given?
|
|
219
|
-
@custom_handler = block
|
|
220
|
-
else
|
|
221
|
-
@custom_handler || method(:default_auth_handler)
|
|
222
|
-
end
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
def bearer_auth
|
|
226
|
-
lambda do |env|
|
|
227
|
-
auth_header = env["HTTP_AUTHORIZATION"] || ""
|
|
228
|
-
return false unless auth_header =~ /\ABearer\s+(.+)\z/i
|
|
229
|
-
|
|
230
|
-
token = Regexp.last_match(1)
|
|
231
|
-
|
|
232
|
-
# API_KEY bypass — matches tina4_python behavior
|
|
233
|
-
api_key = ENV["TINA4_API_KEY"] || ENV["API_KEY"]
|
|
234
|
-
if api_key && !api_key.empty? && token == api_key
|
|
235
|
-
env["tina4.auth"] = { "api_key" => true }
|
|
236
|
-
return true
|
|
237
|
-
end
|
|
238
|
-
|
|
239
|
-
if valid_token(token)
|
|
240
|
-
env["tina4.auth"] = get_payload(token)
|
|
241
|
-
true
|
|
242
|
-
else
|
|
243
|
-
false
|
|
244
|
-
end
|
|
245
|
-
end
|
|
246
|
-
end
|
|
247
|
-
|
|
248
|
-
# Default auth handler for secured routes (POST/PUT/PATCH/DELETE)
|
|
249
|
-
# Used automatically unless auth: false is passed
|
|
250
|
-
def default_secure_auth
|
|
251
|
-
@default_secure_auth ||= bearer_auth
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
# Legacy aliases
|
|
255
|
-
alias_method :create_token, :get_token
|
|
256
|
-
alias_method :validate_token, :valid_token_detail
|
|
257
|
-
|
|
258
|
-
def private_key
|
|
259
|
-
@private_key ||= OpenSSL::PKey::RSA.new(File.read(private_key_path))
|
|
260
|
-
end
|
|
261
|
-
|
|
262
|
-
def public_key
|
|
263
|
-
@public_key ||= OpenSSL::PKey::RSA.new(File.read(public_key_path))
|
|
264
|
-
end
|
|
265
|
-
|
|
266
|
-
private
|
|
267
|
-
|
|
268
|
-
def ensure_keys
|
|
269
|
-
@keys_dir ||= File.join(Dir.pwd, KEYS_DIR)
|
|
270
|
-
FileUtils.mkdir_p(@keys_dir)
|
|
271
|
-
unless File.exist?(private_key_path) && File.exist?(public_key_path)
|
|
272
|
-
generate_keys
|
|
273
|
-
end
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
def generate_keys
|
|
277
|
-
Tina4::Log.info("Generating RSA key pair for JWT authentication")
|
|
278
|
-
key = OpenSSL::PKey::RSA.generate(2048)
|
|
279
|
-
File.write(private_key_path, key.to_pem)
|
|
280
|
-
File.write(public_key_path, key.public_key.to_pem)
|
|
281
|
-
@private_key = nil
|
|
282
|
-
@public_key = nil
|
|
283
|
-
end
|
|
284
|
-
|
|
285
|
-
def private_key_path
|
|
286
|
-
File.join(@keys_dir, "private.pem")
|
|
287
|
-
end
|
|
288
|
-
|
|
289
|
-
def public_key_path
|
|
290
|
-
File.join(@keys_dir, "public.pem")
|
|
291
|
-
end
|
|
292
|
-
|
|
293
|
-
def default_auth_handler(env)
|
|
294
|
-
auth_header = env["HTTP_AUTHORIZATION"] || ""
|
|
295
|
-
return true if auth_header.empty?
|
|
296
|
-
|
|
297
|
-
if auth_header =~ /\ABearer\s+(.+)\z/i
|
|
298
|
-
valid_token(Regexp.last_match(1))
|
|
299
|
-
else
|
|
300
|
-
false
|
|
301
|
-
end
|
|
302
|
-
end
|
|
303
|
-
end
|
|
304
|
-
end
|
|
305
|
-
end
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "openssl"
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module Tina4
|
|
8
|
+
module Auth
|
|
9
|
+
KEYS_DIR = ".keys"
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def setup(root_dir = Dir.pwd)
|
|
13
|
+
@keys_dir = File.join(root_dir, KEYS_DIR)
|
|
14
|
+
FileUtils.mkdir_p(@keys_dir)
|
|
15
|
+
ensure_keys
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# ── HS256 helpers (stdlib only, no gem) ──────────────────────
|
|
19
|
+
|
|
20
|
+
# Returns true when SECRET env var is set and no RSA keys exist in .keys/
|
|
21
|
+
def use_hmac?
|
|
22
|
+
secret = ENV["SECRET"]
|
|
23
|
+
return false if secret.nil? || secret.empty?
|
|
24
|
+
|
|
25
|
+
# If RSA keys already exist on disk, prefer RS256 for backward compat
|
|
26
|
+
@keys_dir ||= File.join(Dir.pwd, KEYS_DIR)
|
|
27
|
+
!(File.exist?(File.join(@keys_dir, "private.pem")) &&
|
|
28
|
+
File.exist?(File.join(@keys_dir, "public.pem")))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def hmac_secret
|
|
32
|
+
ENV["SECRET"]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Base64url-encode without padding (JWT spec)
|
|
36
|
+
def base64url_encode(data)
|
|
37
|
+
Base64.urlsafe_encode64(data, padding: false)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Base64url-decode (handles missing padding)
|
|
41
|
+
def base64url_decode(str)
|
|
42
|
+
# Add back padding
|
|
43
|
+
remainder = str.length % 4
|
|
44
|
+
str += "=" * ((4 - remainder) % 4) if remainder != 0
|
|
45
|
+
Base64.urlsafe_decode64(str)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Build a JWT using HS256 with Ruby's OpenSSL::HMAC (no gem needed)
|
|
49
|
+
def hmac_encode(claims, secret)
|
|
50
|
+
header = { "alg" => "HS256", "typ" => "JWT" }
|
|
51
|
+
segments = [
|
|
52
|
+
base64url_encode(JSON.generate(header)),
|
|
53
|
+
base64url_encode(JSON.generate(claims))
|
|
54
|
+
]
|
|
55
|
+
signing_input = segments.join(".")
|
|
56
|
+
signature = OpenSSL::HMAC.digest("SHA256", secret, signing_input)
|
|
57
|
+
segments << base64url_encode(signature)
|
|
58
|
+
segments.join(".")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Decode and verify a JWT signed with HS256. Returns the payload hash or nil.
|
|
62
|
+
def hmac_decode(token, secret)
|
|
63
|
+
parts = token.split(".")
|
|
64
|
+
return nil unless parts.length == 3
|
|
65
|
+
|
|
66
|
+
header_json = base64url_decode(parts[0])
|
|
67
|
+
header = JSON.parse(header_json)
|
|
68
|
+
return nil unless header["alg"] == "HS256"
|
|
69
|
+
|
|
70
|
+
# Verify signature
|
|
71
|
+
signing_input = "#{parts[0]}.#{parts[1]}"
|
|
72
|
+
expected_sig = OpenSSL::HMAC.digest("SHA256", secret, signing_input)
|
|
73
|
+
actual_sig = base64url_decode(parts[2])
|
|
74
|
+
|
|
75
|
+
# Constant-time comparison to prevent timing attacks
|
|
76
|
+
return nil unless OpenSSL.fixed_length_secure_compare(expected_sig, actual_sig)
|
|
77
|
+
|
|
78
|
+
payload = JSON.parse(base64url_decode(parts[1]))
|
|
79
|
+
|
|
80
|
+
# Check expiry
|
|
81
|
+
now = Time.now.to_i
|
|
82
|
+
return nil if payload["exp"] && now >= payload["exp"]
|
|
83
|
+
return nil if payload["nbf"] && now < payload["nbf"]
|
|
84
|
+
|
|
85
|
+
payload
|
|
86
|
+
rescue ArgumentError, JSON::ParserError, OpenSSL::HMACError
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# ── Token API (auto-selects HS256 or RS256) ─────────────────
|
|
91
|
+
|
|
92
|
+
def get_token(payload, expires_in: 60, secret: nil)
|
|
93
|
+
now = Time.now.to_i
|
|
94
|
+
claims = payload.merge(
|
|
95
|
+
"iat" => now,
|
|
96
|
+
"exp" => now + (expires_in * 60).to_i,
|
|
97
|
+
"nbf" => now
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if secret
|
|
101
|
+
hmac_encode(claims, secret)
|
|
102
|
+
elsif use_hmac?
|
|
103
|
+
hmac_encode(claims, hmac_secret)
|
|
104
|
+
else
|
|
105
|
+
ensure_keys
|
|
106
|
+
require "jwt"
|
|
107
|
+
JWT.encode(claims, private_key, "RS256")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def valid_token(token) # -> bool
|
|
113
|
+
if use_hmac?
|
|
114
|
+
!hmac_decode(token, hmac_secret).nil?
|
|
115
|
+
else
|
|
116
|
+
ensure_keys
|
|
117
|
+
require "jwt"
|
|
118
|
+
JWT.decode(token, public_key, true, algorithm: "RS256")
|
|
119
|
+
true
|
|
120
|
+
end
|
|
121
|
+
rescue JWT::ExpiredSignature
|
|
122
|
+
false
|
|
123
|
+
rescue JWT::DecodeError
|
|
124
|
+
false
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def valid_token_detail(token)
|
|
128
|
+
if use_hmac?
|
|
129
|
+
payload = hmac_decode(token, hmac_secret)
|
|
130
|
+
if payload
|
|
131
|
+
{ valid: true, payload: payload }
|
|
132
|
+
else
|
|
133
|
+
{ valid: false, error: "Invalid or expired token" }
|
|
134
|
+
end
|
|
135
|
+
else
|
|
136
|
+
ensure_keys
|
|
137
|
+
require "jwt"
|
|
138
|
+
decoded = JWT.decode(token, public_key, true, algorithm: "RS256")
|
|
139
|
+
{ valid: true, payload: decoded[0] }
|
|
140
|
+
end
|
|
141
|
+
rescue JWT::ExpiredSignature
|
|
142
|
+
{ valid: false, error: "Token expired" }
|
|
143
|
+
rescue JWT::DecodeError => e
|
|
144
|
+
{ valid: false, error: e.message }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def hash_password(password, salt = nil, iterations = 260000)
|
|
148
|
+
salt ||= SecureRandom.hex(16)
|
|
149
|
+
dk = OpenSSL::KDF.pbkdf2_hmac(password, salt: salt, iterations: iterations, length: 32, hash: "sha256")
|
|
150
|
+
"pbkdf2_sha256$#{iterations}$#{salt}$#{dk.unpack1('H*')}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def check_password(password, hash)
|
|
154
|
+
parts = hash.split('$')
|
|
155
|
+
return false unless parts.length == 4 && parts[0] == 'pbkdf2_sha256'
|
|
156
|
+
iterations = parts[1].to_i
|
|
157
|
+
salt = parts[2]
|
|
158
|
+
expected = parts[3]
|
|
159
|
+
dk = OpenSSL::KDF.pbkdf2_hmac(password, salt: salt, iterations: iterations, length: 32, hash: "sha256")
|
|
160
|
+
actual = dk.unpack1('H*')
|
|
161
|
+
# Timing-safe comparison
|
|
162
|
+
OpenSSL.fixed_length_secure_compare(actual, expected)
|
|
163
|
+
rescue
|
|
164
|
+
false
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def get_payload(token)
|
|
169
|
+
parts = token.split(".")
|
|
170
|
+
return nil unless parts.length == 3
|
|
171
|
+
|
|
172
|
+
payload_json = base64url_decode(parts[1])
|
|
173
|
+
JSON.parse(payload_json)
|
|
174
|
+
rescue ArgumentError, JSON::ParserError
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def refresh_token(token, expires_in: 60)
|
|
179
|
+
return nil unless valid_token(token)
|
|
180
|
+
|
|
181
|
+
payload = get_payload(token)
|
|
182
|
+
return nil unless payload
|
|
183
|
+
payload = payload.reject { |k, _| %w[iat exp nbf].include?(k) }
|
|
184
|
+
get_token(payload, expires_in: expires_in)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def authenticate_request(headers, secret: nil, algorithm: "HS256")
|
|
188
|
+
auth_header = headers["HTTP_AUTHORIZATION"] || headers["Authorization"] || ""
|
|
189
|
+
return nil unless auth_header =~ /\ABearer\s+(.+)\z/i
|
|
190
|
+
|
|
191
|
+
token = Regexp.last_match(1)
|
|
192
|
+
|
|
193
|
+
# API_KEY bypass — matches tina4_python behavior
|
|
194
|
+
api_key = ENV["TINA4_API_KEY"] || ENV["API_KEY"]
|
|
195
|
+
if api_key && !api_key.empty? && token == api_key
|
|
196
|
+
return { "api_key" => true }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# If a custom secret is provided, validate against it directly
|
|
200
|
+
if secret
|
|
201
|
+
payload = hmac_decode(token, secret)
|
|
202
|
+
return payload ? payload : nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
valid_token(token) ? get_payload(token) : nil
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def validate_api_key(provided, expected: nil)
|
|
209
|
+
expected ||= ENV["TINA4_API_KEY"] || ENV["API_KEY"]
|
|
210
|
+
return false if expected.nil? || expected.empty?
|
|
211
|
+
return false if provided.nil? || provided.empty?
|
|
212
|
+
return false if provided.length != expected.length
|
|
213
|
+
|
|
214
|
+
OpenSSL.fixed_length_secure_compare(provided, expected)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def auth_handler(&block)
|
|
218
|
+
if block_given?
|
|
219
|
+
@custom_handler = block
|
|
220
|
+
else
|
|
221
|
+
@custom_handler || method(:default_auth_handler)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def bearer_auth
|
|
226
|
+
lambda do |env|
|
|
227
|
+
auth_header = env["HTTP_AUTHORIZATION"] || ""
|
|
228
|
+
return false unless auth_header =~ /\ABearer\s+(.+)\z/i
|
|
229
|
+
|
|
230
|
+
token = Regexp.last_match(1)
|
|
231
|
+
|
|
232
|
+
# API_KEY bypass — matches tina4_python behavior
|
|
233
|
+
api_key = ENV["TINA4_API_KEY"] || ENV["API_KEY"]
|
|
234
|
+
if api_key && !api_key.empty? && token == api_key
|
|
235
|
+
env["tina4.auth"] = { "api_key" => true }
|
|
236
|
+
return true
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
if valid_token(token)
|
|
240
|
+
env["tina4.auth"] = get_payload(token)
|
|
241
|
+
true
|
|
242
|
+
else
|
|
243
|
+
false
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Default auth handler for secured routes (POST/PUT/PATCH/DELETE)
|
|
249
|
+
# Used automatically unless auth: false is passed
|
|
250
|
+
def default_secure_auth
|
|
251
|
+
@default_secure_auth ||= bearer_auth
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Legacy aliases
|
|
255
|
+
alias_method :create_token, :get_token
|
|
256
|
+
alias_method :validate_token, :valid_token_detail
|
|
257
|
+
|
|
258
|
+
def private_key
|
|
259
|
+
@private_key ||= OpenSSL::PKey::RSA.new(File.read(private_key_path))
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def public_key
|
|
263
|
+
@public_key ||= OpenSSL::PKey::RSA.new(File.read(public_key_path))
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
private
|
|
267
|
+
|
|
268
|
+
def ensure_keys
|
|
269
|
+
@keys_dir ||= File.join(Dir.pwd, KEYS_DIR)
|
|
270
|
+
FileUtils.mkdir_p(@keys_dir)
|
|
271
|
+
unless File.exist?(private_key_path) && File.exist?(public_key_path)
|
|
272
|
+
generate_keys
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def generate_keys
|
|
277
|
+
Tina4::Log.info("Generating RSA key pair for JWT authentication")
|
|
278
|
+
key = OpenSSL::PKey::RSA.generate(2048)
|
|
279
|
+
File.write(private_key_path, key.to_pem)
|
|
280
|
+
File.write(public_key_path, key.public_key.to_pem)
|
|
281
|
+
@private_key = nil
|
|
282
|
+
@public_key = nil
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def private_key_path
|
|
286
|
+
File.join(@keys_dir, "private.pem")
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def public_key_path
|
|
290
|
+
File.join(@keys_dir, "public.pem")
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def default_auth_handler(env)
|
|
294
|
+
auth_header = env["HTTP_AUTHORIZATION"] || ""
|
|
295
|
+
return true if auth_header.empty?
|
|
296
|
+
|
|
297
|
+
if auth_header =~ /\ABearer\s+(.+)\z/i
|
|
298
|
+
valid_token(Regexp.last_match(1))
|
|
299
|
+
else
|
|
300
|
+
false
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|