Class: Tina4::Session

Inherits:
Object
  • Object
show all
Defined in:
lib/tina4/session.rb

Constant Summary collapse

DEFAULT_OPTIONS =
{
  secret: nil,
  max_age: 3600,
  handler: :file,
  handler_options: {}
}.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(env, options = {}) ⇒ Session

Returns a new instance of Session.



29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# File 'lib/tina4/session.rb', line 29

def initialize(env, options = {})
  @options = DEFAULT_OPTIONS.merge(options)
  # TINA4_SESSION_NAME — resolved by the ONE shared resolver (Session.cookie_name)
  # that BOTH the write path (#cookie_header) and the read paths
  # (#extract_session_id + RackApp incoming-cookie parse) go through, so a
  # renamed cookie is emitted and read back under the same name. An explicit
  # :cookie_name option still wins (same precedence as :handler below).
  @options[:cookie_name] = self.class.cookie_name unless options.key?(:cookie_name)
  # No guessable built-in secret. The session never signs with this value
  # (IDs are SecureRandom.hex(32)), so we resolve it from TINA4_SECRET only
  # — nil when unset. This honours the framework's blank-secret discipline
  # (Auth.ensure_dev_secret never uses a guessable default); Python/Node
  # sessions carry no secret field at all.
  @options[:secret] ||= ENV["TINA4_SECRET"]
  # TINA4_SESSION_TTL — cookie Max-Age (parity with Python's Session._ttl,
  # which reads the same var). Only consulted when the caller did NOT pass
  # an explicit :max_age (an explicit option always wins). Keeps the cookie
  # honouring TINA4_SESSION_TTL now that rack_app routes the Set-Cookie
  # through #cookie_header instead of hand-writing it (issue #31).
  unless options.key?(:max_age)
    ttl_env = ENV["TINA4_SESSION_TTL"]
    @options[:max_age] = Integer(ttl_env) if ttl_env && !ttl_env.strip.empty?
  end
  # TINA4_SESSION_BACKEND — selects the storage handler unless the caller
  # explicitly passed :handler (same precedence as :cookie_name above; an
  # explicit option always wins over the environment). Without this the
  # shipped redis/valkey/mongo/database handlers are unreachable by config.
  # Parity with Python's Session._resolve_handler.
  @options[:handler] = env_session_backend || @options[:handler] unless options.key?(:handler)
  # Backend-failure policy strict flag (parity with Python's
  # TINA4_SESSION_STRICT). When truthy, read/write/destroy/gc failures
  # RE-RAISE instead of logging + degrading.
  @strict = Tina4::Env.is_truthy(ENV["TINA4_SESSION_STRICT"])
  # Whether THIS request reached us over https, decided PROXY-AWARE from the
  # Rack env at construction (x-forwarded-proto first hop, else rack scheme /
  # HTTPS). #cookie_header ORs this into the Secure flag so a cookie set on an
  # https request is Secure even when TINA4_SESSION_SECURE is unset — a
  # session cookie for an encrypted request must never be sent in the clear.
  # Uses the SAME detector as Request#url so the two never disagree (issue #31).
  @request_secure = Tina4::Request.secure_scheme?(env || {})
  @handler = create_handler
  @id = extract_session_id(env) || SecureRandom.hex(32)
  @data = load_session
  @modified = false
end

Instance Attribute Details

#dataObject (readonly)

Returns the value of attribute data.



14
15
16
# File 'lib/tina4/session.rb', line 14

def data
  @data
end

#idObject (readonly)

Returns the value of attribute id.



14
15
16
# File 'lib/tina4/session.rb', line 14

def id
  @id
end

Class Method Details

The session cookie name — the SINGLE source of truth shared by the WRITE side (#cookie_header) and the READ side (#extract_session_id AND RackApp's incoming-cookie parse), so a cookie written under a renamed name is read back on the next request. Keeping the default literal in ONE place means it can never drift between the emit and parse paths. Parity with Python's module-level session_cookie_name() (tina4_python/session/init.py).

TINA4_SESSION_NAME   Cookie name (default: tina4_session)


24
25
26
27
# File 'lib/tina4/session.rb', line 24

def self.cookie_name
  name = ENV["TINA4_SESSION_NAME"]
  name.nil? || name.empty? ? "tina4_session" : name
end

Instance Method Details

#[](key) ⇒ Object



75
76
77
# File 'lib/tina4/session.rb', line 75

def [](key)
  @data[key.to_s]
end

#[]=(key, value) ⇒ Object



79
80
81
82
# File 'lib/tina4/session.rb', line 79

def []=(key, value)
  @data[key.to_s] = value
  @modified = true
end

#allObject

Return all session data



135
136
137
# File 'lib/tina4/session.rb', line 135

def all
  @data.dup
end

#clearObject



89
90
91
92
# File 'lib/tina4/session.rb', line 89

def clear
  @data = {}
  @modified = true
end


217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# File 'lib/tina4/session.rb', line 217

def cookie_header(cookie_name = nil)
  name = cookie_name || @options[:cookie_name]
  samesite = ENV["TINA4_SESSION_SAMESITE"] || "Lax"
  # HttpOnly defaults to true (existing behaviour); flip off only when explicitly false.
  httponly = !%w[false 0 no off].include?((ENV["TINA4_SESSION_HTTPONLY"] || "true").to_s.strip.downcase)
  # Secure is set when ANY of the unified-contract conditions hold (parity
  # with tina4-php#175/#179): TINA4_SESSION_SECURE is truthy, OR SameSite is
  # None (browsers reject a None cookie without Secure per RFC 6265bis), OR
  # the request itself is https (proxy-aware, from @request_secure). A plain
  # HTTP request with the flag unset and SameSite != None stays non-Secure.
  secure = %w[true 1 yes on].include?((ENV["TINA4_SESSION_SECURE"] || "false").to_s.strip.downcase) ||
           samesite.to_s.strip.casecmp("none").zero? ||
           @request_secure == true

  parts = ["#{name}=#{@id}", "Path=/"]
  parts << "HttpOnly" if httponly
  parts << "Secure" if secure
  parts << "SameSite=#{samesite}"
  parts << "Max-Age=#{@options[:max_age]}"
  parts.join("; ")
end

#delete(key) ⇒ Object



84
85
86
87
# File 'lib/tina4/session.rb', line 84

def delete(key)
  @data.delete(key.to_s)
  @modified = true
end

#destroyObject

Destroy the current session. Should be called right after login or any privilege change to defend against session fixation (see #regenerate).



113
114
115
116
# File 'lib/tina4/session.rb', line 113

def destroy
  safe_destroy(@id)
  @data = {}
end

#flash(key, value = nil) ⇒ Object

Flash data: set a value that is removed after next read. Call with value to set, call without value to get (and remove).



141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/tina4/session.rb', line 141

def flash(key, value = nil)
  flash_key = "_flash_#{key}"
  if value.nil?
    val = @data.delete(flash_key.to_s)
    @modified = true if val
    val
  else
    @data[flash_key.to_s] = value
    @modified = true
    value
  end
end

#gc(max_lifetime = nil) ⇒ Object

Garbage collection: remove expired sessions from the handler. A backend failure is logged and swallowed (never crashes the request).



207
208
209
210
211
212
213
214
215
# File 'lib/tina4/session.rb', line 207

def gc(max_lifetime = nil)
  return unless @handler.respond_to?(:gc)
  max_lifetime ||= @options[:max_age]
  @handler.gc(max_lifetime)
rescue StandardError => e
  log_backend_error("gc", e)
  raise if @strict
  nil
end

#get(key, default = nil) ⇒ Object

Get a session value with optional default



119
120
121
# File 'lib/tina4/session.rb', line 119

def get(key, default = nil)
  @data[key.to_s] || default
end

#get_flash(key, default = nil) ⇒ Object

Get flash data by key (alias for flash(key) without value)



155
156
157
158
# File 'lib/tina4/session.rb', line 155

def get_flash(key, default = nil)
  result = flash(key)
  result.nil? ? default : result
end

#get_session_idObject

Returns the current session ID string.



189
190
191
# File 'lib/tina4/session.rb', line 189

def get_session_id
  @id
end

#has?(key) ⇒ Boolean

Check if a key exists in the session

Returns:

  • (Boolean)


130
131
132
# File 'lib/tina4/session.rb', line 130

def has?(key)
  @data.key?(key.to_s)
end

#read(session_id) ⇒ Object

Reads raw session data for a given session ID from backend storage. Returns the data hash, or {} on a backend failure (logged + degraded).



195
196
197
# File 'lib/tina4/session.rb', line 195

def read(session_id)
  safe_read(session_id)
end

#regenerateObject

Regenerate the session ID while preserving data — returns the new ID. Call this right after login or any privilege change to defend against session fixation (a pre-auth session ID must not survive into the authenticated session). Destroys the old backend record (best-effort) and persists under the new ID.



165
166
167
168
169
170
171
172
# File 'lib/tina4/session.rb', line 165

def regenerate
  old_id = @id
  @id = SecureRandom.hex(32)
  safe_destroy(old_id)
  @modified = true
  save
  @id
end

#saveObject

Persist the session if dirty. On a backend write failure the error is logged and false is returned — the @modified (dirty) flag is RETAINED so a later save can retry. Returns true on a successful (or no-op) write.



101
102
103
104
105
106
107
108
109
# File 'lib/tina4/session.rb', line 101

def save
  return true unless @modified
  if safe_write(@id, @data)
    @modified = false
    true
  else
    false # dirty flag retained for retry
  end
end

#set(key, value) ⇒ Object

Set a session value



124
125
126
127
# File 'lib/tina4/session.rb', line 124

def set(key, value)
  @data[key.to_s] = value
  @modified = true
end

#start(session_id = nil) ⇒ Object

Start or resume a session. If session_id is given, load that session; otherwise generate a new ID. Returns the session ID string.



176
177
178
179
180
181
182
183
184
185
186
# File 'lib/tina4/session.rb', line 176

def start(session_id = nil)
  if session_id
    @id = session_id
    @data = load_session
  else
    @id = SecureRandom.hex(32)
    @data = {}
  end
  @modified = false
  @id
end

#to_hashObject



94
95
96
# File 'lib/tina4/session.rb', line 94

def to_hash
  @data.dup
end

#write(session_id, data, ttl = nil) ⇒ Object

Writes raw session data for a given session ID to backend storage. Returns true on success, false on a backend failure (logged + degraded).



201
202
203
# File 'lib/tina4/session.rb', line 201

def write(session_id, data, ttl = nil)
  safe_write(session_id, data, ttl)
end