From 94db0c8cb86481335f45ca9d14fd9c5028011729 Mon Sep 17 00:00:00 2001 From: Mike Perham Date: Mon, 27 Jun 2016 14:37:40 -0700 Subject: [PATCH 1/2] 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. --- spec/session_spec.cr | 57 ++++++++++++++++++++++++++ src/kemal.cr | 2 + src/kemal/context.cr | 5 +++ src/kemal/session.cr | 96 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 160 insertions(+) create mode 100644 spec/session_spec.cr create mode 100644 src/kemal/session.cr diff --git a/spec/session_spec.cr b/spec/session_spec.cr new file mode 100644 index 0000000..88cf1f1 --- /dev/null +++ b/spec/session_spec.cr @@ -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 diff --git a/src/kemal.cr b/src/kemal.cr index b89a137..c087bbc 100644 --- a/src/kemal.cr +++ b/src/kemal.cr @@ -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 diff --git a/src/kemal/context.cr b/src/kemal/context.cr index f1c7023..7e53f88 100644 --- a/src/kemal/context.cr +++ b/src/kemal/context.cr @@ -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 diff --git a/src/kemal/session.cr b/src/kemal/session.cr new file mode 100644 index 0000000..1e0e9e2 --- /dev/null +++ b/src/kemal/session.cr @@ -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 From 46b4dc652463655a0643e36c744ed764ab1deef5 Mon Sep 17 00:00:00 2001 From: Mike Perham Date: Mon, 27 Jun 2016 16:24:03 -0700 Subject: [PATCH 2/2] Add Session#delete, more docs --- spec/session_spec.cr | 1 + src/kemal/session.cr | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/spec/session_spec.cr b/spec/session_spec.cr index 88cf1f1..b097039 100644 --- a/spec/session_spec.cr +++ b/spec/session_spec.cr @@ -7,6 +7,7 @@ describe "Session" do get "/" do |env| sess = env.session existing = sess["token"]? + sess.delete("token") sid = sess.id sess["token"] = "abc" "Hello" diff --git a/src/kemal/session.cr b/src/kemal/session.cr index 1e0e9e2..9b1685f 100644 --- a/src/kemal/session.cr +++ b/src/kemal/session.cr @@ -3,6 +3,17 @@ 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. + # + # Kemal handlers can access the session like so: + # + # get("/") do |env| + # env.session["abc"] = "xyz" + # uid = env.session["user_id"]? + # end + # + # Note that only String values are allowed. + # + # Sessions are pruned hourly after 48 hours of inactivity. class Sessions NAME = "SessionId" @@ -44,6 +55,11 @@ module Kemal @last_access_at = Time.now.epoch_ms @store[key] = value end + + def delete(key : String) + @last_access_at = Time.now.epoch_ms + @store.delete(key) + end end getter! id : String @@ -76,6 +92,10 @@ module Kemal STORE[@id]?.try &.[key]? end + def delete(key : String) + STORE[@id]?.try &.delete(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