Class: StandardId::Session
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- StandardId::Session
- Defined in:
- app/models/standard_id/session.rb
Direct Known Subclasses
Constant Summary collapse
- DIGEST_PREFIX =
Keyed digest of an opaque session token — the default scheme.
HMAC-SHA256 under
secret_key_base, domain-separated fromlookup_hashby both construction (HMAC vs plain SHA256) and an explicit versioned prefix, so the two stored values can never coincide even though both derive from the same token and secret.Why not BCrypt (which this was, and which
token_digest_coststill selects): BCrypt's cost factor exists to make brute-force of a LOW-entropy secret expensive. Session tokens areSecureRandom.urlsafe_base64(32)— 256 bits — so guessing one is infeasible regardless of how fast the hash is, and the stretching bought nothing while costing a measured ~181 ms of CPU on EVERY authenticated request (cost 12). That is the same reasoning under whichRefreshTokendigests with plain SHA256 andlookup_hashwith SHA256; this brings session tokens in line with both. "standard_id.session.token_digest.v1:".freeze
Instance Attribute Summary collapse
-
#token ⇒ Object
readonly
Returns the value of attribute token.
Class Method Summary collapse
-
.authenticate_by_token(token) ⇒ StandardId::Session?
Authenticate an opaque session token.
- .hmac_token_digest(token) ⇒ Object
Instance Method Summary collapse
- #active? ⇒ Boolean
-
#authenticate_token(token) ⇒ Boolean
Timing-safe verification of
tokenagainst this session's stored digest. - #expired? ⇒ Boolean
- #revoke!(reason: nil) ⇒ Object
- #revoked? ⇒ Boolean
Instance Attribute Details
#token ⇒ Object (readonly)
Returns the value of attribute token.
100 101 102 |
# File 'app/models/standard_id/session.rb', line 100 def token @token end |
Class Method Details
.authenticate_by_token(token) ⇒ StandardId::Session?
Authenticate an opaque session token.
by_token is NOT authentication on its own: it matches the SHA256
lookup_hash, which exists only to find the candidate row from an
indexed column. The credential is token_digest (see #authenticate_token
for the two schemes it may hold), and every consumer previously had to
remember to verify it by hand — and to rescue BCrypt::Errors::InvalidHash.
This is that step, done once, here.
Honours the current scope, so callers keep their own filters:
StandardId::Session.api_compatible.active.authenticate_by_token(token)
38 39 40 41 42 43 44 45 |
# File 'app/models/standard_id/session.rb', line 38 def self.authenticate_by_token(token) return nil if token.blank? session = by_token(token).first return nil if session.nil? session.authenticate_token(token) ? session : nil end |
.hmac_token_digest(token) ⇒ Object
64 65 66 67 68 |
# File 'app/models/standard_id/session.rb', line 64 def self.hmac_token_digest(token) OpenSSL::HMAC.hexdigest( "SHA256", Rails.configuration.secret_key_base, "#{DIGEST_PREFIX}#{token}" ) end |
Instance Method Details
#active? ⇒ Boolean
105 106 107 |
# File 'app/models/standard_id/session.rb', line 105 def active? !revoked? && !expired? end |
#authenticate_token(token) ⇒ Boolean
Timing-safe verification of token against this session's stored digest.
Handles BOTH schemes, because digests written before the HMAC default —
and any written since by an app that sets token_digest_cost — are
BCrypt. A BCrypt digest is self-identifying by its $2<x>$ prefix, so the
scheme is read off the stored value rather than off configuration; that
way a config change never strands existing sessions, and no rewrite of
stored digests is needed. Both branches end in a constant-time compare, so
the response time carries no information about how much of the digest
matched.
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 |
# File 'app/models/standard_id/session.rb', line 82 def authenticate_token(token) return false if token.blank? || token_digest.blank? if token_digest.start_with?("$2") stored = BCrypt::Password.new(token_digest) ActiveSupport::SecurityUtils.secure_compare( stored.to_s, BCrypt::Engine.hash_secret(token, stored.salt) ) else ActiveSupport::SecurityUtils.secure_compare( token_digest, self.class.hmac_token_digest(token) ) end rescue BCrypt::Errors::InvalidHash, BCrypt::Errors::InvalidSalt false end |
#expired? ⇒ Boolean
109 110 111 |
# File 'app/models/standard_id/session.rb', line 109 def expired? expires_at <= Time.current end |
#revoke!(reason: nil) ⇒ Object
117 118 119 120 121 122 123 124 125 |
# File 'app/models/standard_id/session.rb', line 117 def revoke!(reason: nil) @reason = reason transaction do update!(revoked_at: Time.current) # Cascade revocation to refresh tokens. Uses update_all for efficiency; # intentionally skips updated_at since revocation is tracked via revoked_at. refresh_tokens.active.update_all(revoked_at: Time.current) end end |
#revoked? ⇒ Boolean
113 114 115 |
# File 'app/models/standard_id/session.rb', line 113 def revoked? revoked_at.present? end |