mirror of
https://gitea.invidious.io/iv-org/shard-kemal.git
synced 2024-08-15 00:53:36 +00:00
Merge pull request #170 from mperham/master
Implement basic in-memory session store
This commit is contained in:
commit
7e49237468
4 changed files with 181 additions and 0 deletions
58
spec/session_spec.cr
Normal file
58
spec/session_spec.cr
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
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"]?
|
||||||
|
sess.delete("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
|
|
@ -12,6 +12,8 @@ module Kemal
|
||||||
config.server = HTTP::Server.new(config.host_binding.not_nil!, config.port, config.handlers)
|
config.server = HTTP::Server.new(config.host_binding.not_nil!, config.port, config.handlers)
|
||||||
config.server.not_nil!.tls = config.ssl
|
config.server.not_nil!.tls = config.ssl
|
||||||
|
|
||||||
|
Kemal::Sessions.run_reaper!
|
||||||
|
|
||||||
unless Kemal.config.error_handlers.has_key?(404)
|
unless Kemal.config.error_handlers.has_key?(404)
|
||||||
error 404 do |env|
|
error 404 do |env|
|
||||||
render_404
|
render_404
|
||||||
|
|
|
@ -19,5 +19,10 @@ class HTTP::Server
|
||||||
def route_defined?
|
def route_defined?
|
||||||
route_lookup.found?
|
route_lookup.found?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def session
|
||||||
|
@session ||= Kemal::Sessions.new(self)
|
||||||
|
@session.not_nil!
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
116
src/kemal/session.cr
Normal file
116
src/kemal/session.cr
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
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"
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
def delete(key : String)
|
||||||
|
@last_access_at = Time.now.epoch_ms
|
||||||
|
@store.delete(key)
|
||||||
|
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 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
|
||||||
|
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
|
Loading…
Reference in a new issue