feat(orm): add Prism and Orm

This commit is contained in:
dualfroz committed 2026-09-08 17:50:30 +02:00
1 parent 84a04f212d
commit 1e3485dc40
2 files changed
+154

No files matched your search

+116
View File
@@ -0,0 +1,116 @@
require 'openssl'
require 'securerandom'
module Prism
module Orm
# `has_secure_password`, Rails-style, without the bcrypt dependency: the
# digest is PBKDF2-HMAC-SHA256 (stdlib OpenSSL, no gem needed) stored as
# "iterations$salt$hash" so the cost factor can grow later without
# invalidating digests written by an older version.
module SecurePassword
Iterations = 20_000
KeyLength = 32
def self.included(base)
base.extend(ClassMethods)
end
module ClassMethods
def secure_password_attributes
@secure_password_attributes ||= []
end
# Defines `password`/`password=`/`password_confirmation=` (virtual,
# never persisted) plus `authenticate_password` on top of a real
# `password_digest` column, and wires presence/confirmation checks
# into the model's own validation pipeline.
def has_secure_password(attribute = :password, validations: true)
attribute = attribute.to_sym
secure_password_attributes << attribute
digest_column = "#{attribute}_digest"
define_secure_password_reader(attribute)
define_secure_password_writer(attribute, digest_column)
define_secure_password_confirmation_writer(attribute)
define_secure_password_authenticator(attribute, digest_column)
hide_from_json(digest_column) if respond_to?(:hide_from_json)
validate { validate_secure_password(attribute, digest_column) } if validations
end
private
def define_secure_password_reader(attribute)
define_method(attribute) { secure_password_transient[attribute] }
end
def define_secure_password_writer(attribute, digest_column)
define_method("#{attribute}=") do |unencrypted|
secure_password_transient[attribute] = unencrypted
next if unencrypted.nil? || unencrypted.empty?
self[digest_column] = SecurePassword.digest(unencrypted)
end
end
def define_secure_password_confirmation_writer(attribute)
define_method("#{attribute}_confirmation=") do |unencrypted|
secure_password_transient["#{attribute}_confirmation"] = unencrypted
end
end
def define_secure_password_authenticator(attribute, digest_column)
method_name = attribute == :password ? :authenticate : "authenticate_#{attribute}"
define_method(method_name) do |unencrypted|
digest = self[digest_column]
return false if digest.nil? || digest.empty?
SecurePassword.verify(digest, unencrypted) ? self : false
end
end
end
# PBKDF2-HMAC-SHA256 with a fresh random salt per password.
def self.digest(unencrypted_password)
salt = SecureRandom.hex(16)
"#{Iterations}$#{salt}$#{hexdigest(unencrypted_password, salt, Iterations)}"
end
def self.verify(digest, unencrypted_password)
iterations, salt, expected = digest.split('$', 3)
return false unless iterations && salt && expected
candidate = hexdigest(unencrypted_password, salt, iterations.to_i)
secure_compare(candidate, expected)
end
def self.hexdigest(password, salt, iterations)
OpenSSL::PKCS5.pbkdf2_hmac(password.to_s, salt, iterations, KeyLength, OpenSSL::Digest.new('SHA256')).unpack1('H*')
end
# Constant-time comparison so a timing attack cannot narrow down a
# correct digest one byte at a time.
def self.secure_compare(a, b)
return false unless a.bytesize == b.bytesize
left = a.unpack('C*')
result = 0
b.each_byte.with_index { |byte, index| result |= byte ^ left[index] }
result.zero?
end
private
def secure_password_transient
@secure_password_transient ||= {}
end
def validate_secure_password(attribute, digest_column)
transient = secure_password_transient
confirmation = transient["#{attribute}_confirmation"]
if confirmation && transient[attribute] != confirmation
errors.add(attribute, "doesn't match confirmation")
end
current_digest = self[digest_column]
errors.add(attribute, 'must be present') if new_record? && (current_digest.nil? || current_digest.empty?)
end
end
end
end
+38
View File
@@ -0,0 +1,38 @@
require 'securerandom'
module Prism
module Orm
# `has_secure_token`, Rails-style: assigns a random URL-safe token to a
# column right before the record is first created (unless one was
# already assigned by hand), plus a `regenerate_<name>!` that persists a
# freshly generated replacement. No collision retry - like Rails, a
# 24-byte token is treated as unique enough on its own; pair the column
# with `validates ..., uniqueness: true` if a guarantee is required.
module SecureToken
def self.included(base)
base.extend(ClassMethods)
end
module ClassMethods
def secure_token_attributes
@secure_token_attributes ||= []
end
def has_secure_token(attribute = :token, length: 24)
attribute = attribute.to_sym
secure_token_attributes << attribute
before_create { self[attribute] = SecureToken.generate(length) if self[attribute].to_s.empty? }
define_method("regenerate_#{attribute}!") do
update!(attribute => SecureToken.generate(length))
end
end
end
def self.generate(length = 24)
SecureRandom.urlsafe_base64(length, padding: false)
end
end
end
end