Implement basic in-memory session store

Sessions are stored in a non-persistent Hash.  Only String values are allowed.  A reaper fiber regularly removes any sessions which expire due to inactivity.
This commit is contained in:
Mike Perham 2016-06-27 14:37:40 -07:00
parent 0c46bd65da
commit 94db0c8cb8
4 changed files with 160 additions and 0 deletions

57
spec/session_spec.cr Normal file
View File

@ -0,0 +1,57 @@
require "./spec_helper"
describe "Session" do
it "can establish a session" do
sid = nil
existing = nil
get "/" do |env|
sess = env.session
existing = sess["token"]?
sid = sess.id
sess["token"] = "abc"
"Hello"
end
# make first request without any cookies/session
request = HTTP::Request.new("GET", "/")
response = call_request_on_app(request)
# dup headers due to Crystal#2920
headers = response.headers.dup
# verify we got a cookie and session ID
cookie = response.headers["Set-Cookie"]?
cookie.should_not be_nil
response.cookies[Kemal::Sessions::NAME].value.should eq(sid)
lastsid = sid
existing.should be_nil
# make second request with cookies to get session
request = HTTP::Request.new("GET", "/", headers)
response = call_request_on_app(request)
# verify we got cookies and we could see values set
# in the previous request
cookie2 = response.headers["Set-Cookie"]?
cookie2.should_not be_nil
cookie2.should eq(cookie)
response.cookies[Kemal::Sessions::NAME].value.should eq(lastsid)
existing.should eq("abc")
end
it "can prune old sessions" do
s = Kemal::Sessions::STORE
s.clear
Kemal::Sessions.prune!
id = "foo"
s[id] = Kemal::Sessions::Session.new(id)
s.size.should eq(1)
Kemal::Sessions.prune!
s.size.should eq(1)
s[id].last_access_at = (Time.now - 1.week).epoch_ms
Kemal::Sessions.prune!
s.size.should eq(0)
end
end

View File

@ -12,6 +12,8 @@ module Kemal
config.server = HTTP::Server.new(config.host_binding.not_nil!, config.port, config.handlers)
config.server.not_nil!.tls = config.ssl
Kemal::Sessions.run_reaper!
unless Kemal.config.error_handlers.has_key?(404)
error 404 do |env|
render_404

View File

@ -19,5 +19,10 @@ class HTTP::Server
def route_defined?
route_lookup.found?
end
def session
@session ||= Kemal::Sessions.new(self)
@session.not_nil!
end
end
end

96
src/kemal/session.cr Normal file
View File

@ -0,0 +1,96 @@
require "secure_random"
module Kemal
# Kemal's default session is in-memory only and holds simple String values only.
# The client-side cookie stores a random ID.
class Sessions
NAME = "SessionId"
# I hate websites which require daily login so the default
# inactivity timeout is 48 hours.
TTL = 48.hours
# In-memory, ephemeral datastore only.
#
# Implementing Redis or Memcached as a datastore
# is left as an exercise to another reader.
#
# Note that the only thing we store on the client-side
# is an opaque, random String. If we actually wanted to
# store any data, we'd need to implement encryption, key
# rotation, tamper-detection and that whole iceberg.
STORE = Hash(String, Session).new
class Session
getter! id : String
property! last_access_at : Int64
def initialize(@id)
@last_access_at = Time.new.epoch_ms
@store = Hash(String, String).new
end
def [](key : String)
@last_access_at = Time.now.epoch_ms
@store[key]
end
def []?(key : String)
@last_access_at = Time.now.epoch_ms
@store[key]?
end
def []=(key : String, value : String)
@last_access_at = Time.now.epoch_ms
@store[key] = value
end
end
getter! id : String
def initialize(ctx : HTTP::Server::Context)
id = ctx.request.cookies[NAME]?.try &.value
if id && id.size == 32
# valid
else
# new or invalid
id = SecureRandom.hex
end
ctx.response.cookies[NAME] = id
@id = id
end
def []=(key : String, value : String)
store = STORE[id]? || begin
STORE[id] = Session.new(id)
end
store[key] = value
end
def [](key : String)
STORE[@id][key]
end
def []?(key : String)
STORE[@id]?.try &.[key]?
end
def self.prune!(before = (Time.now - Kemal::Sessions::TTL).epoch_ms)
Kemal::Sessions::STORE.delete_if { |id, entry| entry.last_access_at < before }
nil
end
# This is an hourly job to prune the in-memory hash of any
# sessions which have expired due to inactivity, otherwise
# we'll have a slow memory leak and possible DDoS vector.
def self.run_reaper!
spawn do
loop do
prune!
sleep 3600
end
end
end
end
end