Class: StandardId::Session
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- StandardId::Session
- Defined in:
- app/models/standard_id/session.rb
Direct Known Subclasses
Defined Under Namespace
Classes: RevocationResult
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
-
.revoke_all_for!(account, reason: nil) ⇒ RevocationResult
Revoke every not-yet-revoked session for an account, cascading to refresh tokens, in two set-based UPDATEs.
-
.revoke_sessions!(sessions, account: nil, reason: nil) ⇒ RevocationResult
Set-based revocation of an explicit collection of sessions.
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.
215 216 217 |
# File 'app/models/standard_id/session.rb', line 215 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)
41 42 43 44 45 46 47 48 |
# File 'app/models/standard_id/session.rb', line 41 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
67 68 69 70 71 |
# File 'app/models/standard_id/session.rb', line 67 def self.hmac_token_digest(token) OpenSSL::HMAC.hexdigest( "SHA256", Rails.configuration.secret_key_base, "#{DIGEST_PREFIX}#{token}" ) end |
.revoke_all_for!(account, reason: nil) ⇒ RevocationResult
Revoke every not-yet-revoked session for an account, cascading to refresh tokens, in two set-based UPDATEs.
Honours the current scope, so callers narrow as they need:
StandardId::Session.revoke_all_for!(account, reason: "password_reset")
StandardId::DeviceSession.active.revoke_all_for!(account, reason: "logout")
Why this exists rather than sessions.each(&:revoke!): the loop is O(N)
UPDATEs plus O(N) refresh-token cascades, and the call sites are admin bulk
actions and password resets.
Events: one SESSION_REVOKED per revoked session, never a single aggregate — subscribers must not need a second code path for bulk revocation. Each is published individually and individually rescued (see .revoke_sessions!).
128 129 130 131 132 133 134 |
# File 'app/models/standard_id/session.rb', line 128 def self.revoke_all_for!(account, reason: nil) account_id = account.is_a?(ActiveRecord::Base) ? account.id : account sessions = where(account_id: account_id).where(revoked_at: nil).to_a account_record = account.is_a?(ActiveRecord::Base) ? account : nil revoke_sessions!(sessions, account: account_record, reason: reason) end |
.revoke_sessions!(sessions, account: nil, reason: nil) ⇒ RevocationResult
Set-based revocation of an explicit collection of sessions.
Bulk-revoke in two queries (one UPDATE per table) instead of issuing session.revoke! per row, which would be O(N) UPDATEs plus another O(N) cascades to refresh_tokens.
Tradeoff: update_all skips ActiveRecord callbacks, so the per-row SESSION_REVOKED event emitted by #revoke! (via its after_commit) does not fire automatically. We re-emit it explicitly below so audit-trail subscribers (account status/locking, etc.) still see one event per revoked session — the semantics are preserved, only the SQL shape has changed.
156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 |
# File 'app/models/standard_id/session.rb', line 156 def self.revoke_sessions!(sessions, account: nil, reason: nil) sessions = sessions.to_a return RevocationResult.new(sessions_revoked: 0, refresh_tokens_revoked: 0) if sessions.empty? now = Time.current session_ids = sessions.map(&:id) refresh_tokens_revoked = 0 ActiveRecord::Base.transaction do StandardId::Session.where(id: session_ids).update_all(revoked_at: now) refresh_tokens_revoked = StandardId::RefreshToken .where(session_id: session_ids, revoked_at: nil) .update_all(revoked_at: now) end publish_session_revocations(sessions, account: account, reason: reason, now: now) RevocationResult.new( sessions_revoked: sessions.size, refresh_tokens_revoked: refresh_tokens_revoked ) end |
Instance Method Details
#active? ⇒ Boolean
220 221 222 |
# File 'app/models/standard_id/session.rb', line 220 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.
85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 |
# File 'app/models/standard_id/session.rb', line 85 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
224 225 226 |
# File 'app/models/standard_id/session.rb', line 224 def expired? expires_at <= Time.current end |
#revoke!(reason: nil) ⇒ Object
232 233 234 235 236 237 238 239 240 |
# File 'app/models/standard_id/session.rb', line 232 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
228 229 230 |
# File 'app/models/standard_id/session.rb', line 228 def revoked? revoked_at.present? end |